SEC1.DK / Blog Aug 2026

RFID & Hardware Hacking

Cutting the Cord: Bringing the Proxmark5 BWM to Life

Niel Nielsen Aug 2026 Proxmark5 / BWM · AT32F435 · ESP32-C2
Proxmark5 BWM AT32F435 ESP32-C2 BLE WiFi AW32001 BQ27427

The BWM (Battery / Wireless Module) is the piece that turns the Proxmark5 from a tethered bench tool into something you can put in a pocket. A battery, a charger, and an ESP32 that bridges the device to BLE and WiFi. It is what a Blueshark is to an RDV4, except the RDV4 version was solved years ago and this one was not.

The upstream install notes said exactly one thing about it: disconnect it, it is not supported yet, it gets in the way of flashing. And mine arrived dead, a brick on the desk that would not enumerate and would not advertise. This is the story of going from that to pm3 -p ble:58:2A:BD:34:F1:82 connecting over the air, with a battery gauge, a charger you can drive from the client, and WiFi as a bonus.

01The dead board

Before any firmware, a hardware mystery. The module was completely unresponsive. No USB, no BLE, nothing. The obvious fear with an unknown board is a corrupted flash or a blown rail, so I went in through the 5-pin production UART header (BOOT / RXD / TXD / 3V3 / GND) expecting to have to reflash the ESP.

It was none of that. The cell, a VXE 502540, 3.7 V 500 mAh Li-ion, had been left seated and had discharged so deeply that the charger's power path never came up. The fix was embarrassingly analog: charge the cell back up (the second USB port on the module feeds the AW32001 charger directly), and the module boots, enumerates, and starts advertising as Proxmark5 over BLE.

Note

No firmware damage, no hardware fault. A deep-discharged pack that a bench supply revived in ten minutes. Worth writing down because "the dead board" is usually the scariest-looking failure and here it was the most benign one. If your BWM is silent, measure the cell before you touch the flash.

02The stack you are actually talking to

The BWM is three chips wearing one connector:

On the other side of the connector is the Proxmark5 itself, an AT32F435/437, not the AT91SAM7 of the classic PM3. Everything below is PM5-only and compiles in behind a platform extra. The interesting part is that the ESP and the AT32 do not speak the Proxmark's native command protocol to each other. Working that out was most of the project.

03Reading the charger and the gauge

First useful milestone: make the PM5 firmware read the charger and gauge over I²C and surface them in the client. hw status now grows a Battery / BWM section: the AW32001 side (power path, fault, charge state, input limit, charge current/voltage/enable) and the BQ27427 side (SoC %, voltage, current, remaining and full capacity, temperature, health).

The gauge needs to know the cell before its numbers mean anything, so there is a one-shot to program the design capacity:

hw bwmsetcap --cap 500   # the fitted cell's mAh, then fully charge once
Warning

Run bwmsetcap once after fitting a battery. Repeating it triggers a gauge config-update that disrupts the Impedance Track learning cycle; the "Battery health" line stays inaccurate until a full charge/ discharge cycle teaches it again.

A companion pyscript, pm5_battery_test.py, polls hw status on an interval and logs the gauge to CSV so you can plot a real discharge curve under load (idle, static HF field, or active 14a polling). Handy for characterising runtime and for exercising the firmware's low-battery auto-shutdown.

Note: a small inconsistency

While staring at that status page I noticed the charger prints VIN_DPM and Charge voltage in volts (4.52 V, 4.095 V) while every gauge field is in milli-units (mV, mA, mAh). Both offending values already exist as integer millivolts in bwm_charger.c before being divided down, so aligning them is a two-line change, and it lives in armsrc/, so it wants a reflash, not just a client rebuild. Small, but the kind of thing that trips a parser.

04The charger kick and the power latch

A board that ships in shipping-mode won't charge until something clears the AW32001's FET_DIS bit. Rather than make that a manual step, the PM5 firmware does a charger "kick" at boot: clear shipping-mode so a freshly-connected pack starts charging on its own. The same bring-up work hardened the PB0 power-latch and the long-press shutdown path, so the device powers up, holds its own rail, and shuts down cleanly on a held button instead of relying on USB power to stay alive.

05The real puzzle: how PM3 traffic reaches the host

I expected the ESP to be a dumb pipe carrying raw PacketCommandNG frames. It is not. The ESP↔AT32 link is UART4 on PA0/PA1 at 460800 8N1, and it speaks a framed house protocol, which the ESP project calls app_com:

FieldValue
Host-command header0x7C / 0xC7
Slave-broadcast header0xD2 / 0xD3
IntegrityCRC-16/CCITT-FALSE
Transparent PM3 traffictunnelled in cmd 5000 (out) / 8089 (in)

So the actual Proxmark protocol rides inside an app_com command as an opaque payload. On the AT32 side that meant writing a UART4 driver and a framing shim that wraps outbound PM3 bytes into app_com and unwraps the inbound side, all behind a new WITH_BWM_FORWARD build gate.

Note: the trap that cost the most time

The tempting shortcut is to reuse the existing host-forwarding path, WITH_FPC_USART_HOST. Don't. That path is AT91-only, and enabling it on a PM5 bricks USB enumeration: the device drops off the bus and you are back to recovery. WITH_BWM_FORWARD is deliberately independent of it.

06The divide-by-zero on connect

With bytes finally flowing, the client crashed on connect, a SIGFPE. The capabilities exchange reports the link baudrate, the client divides by it to size some timing, and the BWM link was reporting 0. Integer divide by zero, straight to a floating-point exception on connect. The fix is to have the BWM path report its real baud so the capabilities value is non-zero. One of those bugs that is invisible until a brand-new transport is the first thing to ever report a zero there.

07The GATT layer

The BLE side is deliberately simple, which is good for tooling and worth stating plainly for anyone auditing it:

PropertyValue
NameProxmark5
Address58:2A:BD:34:F1:82 (LE Public, Espressif OUI; yours will differ)
Service0xAE86 (16-bit)
Data characteristic0xAE88: WRITE / WRITE_NO_RSP / NOTIFY
Securitynone (plaintext GATT, no bonding)

A single SPP-style data characteristic, writes one way, notifications the other, no pairing dance. That is all the transport needs.

08Two ways in: the native transport

The one I like best needs no daemon and no Python. A native BLE transport built straight into the pm3 client (Linux), using a raw L2CAP/ATT socket on CID 4, with no bleak, no bluetoothd dependency. It discovers 0xAE88, subscribes via the CCCD, sends with Write-Command and receives on notifications. Build with the Bluetooth headers, then connect by address:

sudo apt install libbluetooth-dev
make client

./pm3 -p ble:58:2A:BD:34:F1:82
# BLE connected, MTU 512, char handle 0x0014

That is the whole thing. A Proxmark5 on your bench, no cable, from a stock client.

09The cross-platform bridge

Not everyone is on Linux with BlueZ, so there is also pm5_ble_bridge.py, a bleak-based bridge that relays the BWM's BLE SPP to a PTY or a TCP socket the client then opens. It runs on Linux, macOS, Windows, and WSL, with modes for each shape:

python3 pm5_ble_bridge.py --pty          # PTY, simplest
./pm3 -p /tmp/pm5-ble

python3 pm5_ble_bridge.py --tcp 7777     # TCP listener
./pm3 -p tcp:127.0.0.1:7777

The interesting mode is --connect, which dials out to a waiting client (pm3 … --wait). That outbound-only shape exists for one reason: it crosses the WSL2 NAT boundary, where the radio lives on Windows and only an outbound TCP hop reaches the client inside WSL.

# WSL: client listens
./pm3 -p tcp:127.0.0.1:7777 --wait
# Windows (has the radio): bridge dials in
py pm5_ble_bridge.py --connect 127.0.0.1:7777

10And then WiFi, almost for free

Once the ESP was carrying transparent PM3 traffic over one radio, the other radio came cheaply. hw bwmwifi joins your network as a station and starts a TCP server on the module; you connect the client over plain TCP. Configure it over any existing link (USB, or an already-open BLE session):

hw bwmwifi --ssid Home --pwd secret --port 7777 --hostname pm5-lab
# [+] BWM on WiFi at 192.168.1.77
./pm3 -p tcp:192.168.1.77:7777

No mDNS responder, so the IP is the reliable path; hw bwmwifi --status reads back state and IP without reconnecting, which matters because the join occasionally reports failure when the DHCP lease lands a moment late even though the STA actually connected.

Result

A Proxmark5 that runs off its own battery, reports charge and health, charges itself from a dead pack, and talks to the client three ways: native BLE, a portable BLE bridge, and WiFi. End to end, no cable. The module the docs told you to unplug.

11What ships

All of it is upstream in RfidResearchGroup/proxmark3, gated behind the BWM platform extra:

make PLATFORM=PM5 PLATFORM_EXTRAS=BWM
make client

That single extra pulls in WITH_BWM_FORWARD: the UART4 / app_com driver, the battery telemetry, and BLE/WiFi forwarding. Without it you get a plain PM5 with none of the battery commands. Full command reference lives in doc/md/BWM-USAGE.md, and the ESP side is RfidResearchGroup/Proxmark5_BWM_esp32.

12What is next

The gauge learning cycle deserves a proper characterisation writeup: a full discharge under known load, plotted, so the design-capacity and health numbers can be trusted rather than eyeballed. The pm5_battery_test.py logger exists precisely to gather that. And the VIN_DPM / charge-voltage unit inconsistency from §03 is a clean first-PR-sized fix for anyone who wants to touch the firmware without diving into the transport stack.

But the headline is done. The Battery Wireless Module is, at last, actually wireless.