RFID & Hardware Hacking
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.
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.
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.
The BWM is three chips wearing one connector:
RfidResearchGroup/Proxmark5_BWM_esp32): this is the radio bridge,
BLE and WiFi;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.
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
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.
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.
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.
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:
| Field | Value |
|---|---|
| Host-command header | 0x7C / 0xC7 |
| Slave-broadcast header | 0xD2 / 0xD3 |
| Integrity | CRC-16/CCITT-FALSE |
| Transparent PM3 traffic | tunnelled 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.
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.
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.
The BLE side is deliberately simple, which is good for tooling and worth stating plainly for anyone auditing it:
| Property | Value |
|---|---|
| Name | Proxmark5 |
| Address | 58:2A:BD:34:F1:82 (LE Public, Espressif OUI; yours will differ) |
| Service | 0xAE86 (16-bit) |
| Data characteristic | 0xAE88: WRITE / WRITE_NO_RSP / NOTIFY |
| Security | none (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.
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.
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
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.
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.
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.
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.