I’ve got a ZJ Beny BCP-A2N-P EV charger on the side of the house (single phase, 32A, the “OCPP 1.6J with 4G” variant). It’s hooked into Home Assistant through the OCPP integration and honestly it works fine. But at some point I started wondering what was actually running inside it, whether it could ever run ESPHome, and whether anything about the firmware was worth knowing before I trusted it with a 7kW contactor for the next decade. Nobody had published anything on the firmware, so I pulled it apart. This is the write-up.
To be clear about scope up front: this is my own charger, the firmware is what the manufacturer publishes for everyone to download, and nothing was flashed. I haven’t opened the unit and I haven’t sent it anything other than normal OCPP traffic.
Getting the firmware
This turned out to be the easy bit. ZJ Beny host their firmware on a plain HTTP file server (HFS, no authentication) at http://106.15.78.131/, and it’s got everything: a folder per release under /BCP/, around 22 model variants in each, plus a /DEBUG/ tree with verbose builds and a /BCP/Doc/ folder with the release notes. Files are named like BCP-A2N-P_SW1_2_14_HW1.bin, so finding mine was trivial. I grabbed all 11 releases for my model (1.0.39 through 1.2.14) plus every sibling model at 1.2.14 and the debug build, which gave me a corpus of 38 images to work with. That matters later.
The newer “2M” product line under /BCP-2/ is properly encrypted (high entropy, nothing readable). The 2N line I have isn’t, it’s just lightly obfuscated, which is what makes the rest of this possible.
What’s actually inside the box
The first question was whether the brain was an ESP32, because if it was then ESPHome would be on the table. It isn’t. The main MCU is an ARM Cortex-M in the STM32F4/GD32F4 family (the vector table has a stack pointer in SRAM at 0x1000FA20 and the app loads at 0x08020000, with a bootloader living in the 128KB below it). It runs FreeRTOS and it runs everything: the OCPP stack, the little HTML config web UI, the control pilot PWM, the motor lock, energy metering, RFID, dynamic load balancing, RCD self test, contactor weld detection, the lot.
There is an ESP32 on the board, but it’s running stock Espressif ESP-AT firmware and the ARM just talks AT commands to it over UART (AT+CWJAP, AT+CIPSTART, the BLE GATT server commands for the phone app). It’s a Wi-Fi and Bluetooth modem and nothing more. The 4G side is a Quectel EC200 module, also AT driven, and that’s where TLS actually happens (AT+QSSLCFG). The ARM firmware never reflashes the ESP32 either, it just asks it for its version and writes the OCPP CA certificate into its flash.
So “ESPHome on it” is dead on arrival. The chip ESPHome would run on controls nothing. And in practice it doesn’t matter, because pointing the charger’s OCPP at Home Assistant already gives you full local control with no cloud involved. That answered my original question in about an afternoon. The rest of this was curiosity.
The OTA container format
Every firmware file starts with the magic bytes 19 94 11 02 and a 100 byte header:
| Offset | Size | Field |
|---|---|---|
| 0x00 | 4 | Magic 19 94 11 02 |
| 0x04 | 4 | Checksum, u32 big endian (more on this below) |
| 0x08 | 4 | Image length, u32 big endian |
| 0x0C | 2 | Constant 00 01 |
| 0x0E | 2 | Version: 02 0E is 1.2.14 |
| 0x10 | 4 | Constant 00 00 00 01 |
| 0x14 | 80 | Model string, ASCII, NUL padded (e.g. BCP-A2N-P) |
After the header the file is a sequence of 260 byte frames, and every single image on the server satisfies (size - 100) mod 260 == 0 exactly. Each frame is:
[ u16 BE frame counter (0, 1, 2, ...) ] 2 bytes, stored raw
[ 256 bytes of image data, each (b + 1) ] the "obfuscation"
[ u16 BE sum of those 256 original bytes ] 2 bytes, stored raw
The actual flash image is just the 256 byte data chunks decoded (subtract one from each byte) and concatenated. The “+1” is the entire extent of the obfuscation, and it has one slightly evil side effect: erased flash is 0xFF, and 0xFF + 1 wraps to 0x00, so the trailing padding in the file looks like a run of zeros when it’s really a run of 0xFF. That tripped me up more than once.
There are three integrity layers in the container and all of them are plain addition:
| Layer | Where | What it is |
|---|---|---|
| Frame sequencing | First 2 bytes of each frame | Must equal the frame index |
| Frame integrity | Last 2 bytes of each frame | Sum of that frame’s 256 decoded bytes (max 65280, never overflows) |
| Global checksum | Header offset 0x04 | Sum of every decoded image byte from 0 up to the length field, seed zero, header not included |
For 1.2.14 that’s 1280 frames, a length of 327,636 bytes, 44 bytes of 0xFF padding at the end, and the bytes sum to 33,286,238 which is 0x01FBE85E, exactly what’s stored in the header. The last two bytes of the file, which I’d been calling “the trailer” for a while, are just the final frame’s sum. The same rule reproduces all 38 images in the corpus bit for bit, and I can decode an image, repack it, and get back a byte identical file. No cryptography anywhere in the container, no signature, no key.
Why the checksum took two goes
Here’s the honest version, because I think the failure is more instructive than the answer. Most of this project was done with AI models doing the grunt work while I steered, and the checksum is a nice example of how that can go sideways and then right.
The first pass (Claude Opus 4.8) started from the obvious model: a 102 byte header followed by one contiguous blob of “+1” encoded payload. That decodes into something that looks completely fine. The vector table is sane, the strings are readable, Ghidra disassembles it. On that assumption it then did a really thorough job on the checksum: established it was a byte level additive sum (the value tracks image length at almost exactly 102 per byte, which rules out CRCs and word sums immediately), ruled out CRC-32, Adler-32 and Fletcher-32 across every plausible range, and got a decoded byte sum within 0.05% of the stored value on every one of the 38 images. But never exact. The residual wobbled between about +15,000 and +23,000 and then flipped to about −50,000 at release 1.2.12, and it concluded the real summation boundary must be some internal marker only the bootloader knew about. The write-up ended with “characterised but not reproduced, needs an SWD dump of the bootloader to finish”. Which was a perfectly reasonable conclusion from a wrong premise.
The second pass (GLM-5.3) ignored the sums and looked at the structure instead. It noticed the 260 byte periodicity wasn’t a transfer chunk size, it was an on disk layout, and that every 260 bytes there were four bytes that weren’t image data at all. Once you pull those out the whole thing collapses. The length field is in image coordinates, not file coordinates, so using it as a cutoff on the interleaved stream lands mid-string somewhere random, and the “residual” was pure arithmetic of that mis-cut: framing bytes wrongly included minus image bytes never reached. The sign flip at 1.2.12 wasn’t a structural change in the firmware at all, it was just which of those two terms happened to be bigger.
The bit that stings is that the first pass had actually run the right test. One of its scripts tried summing bytes 2 to 258 of every 260 byte frame, decoded, which is exactly the correct window. The residuals it got were −53040, −41820, −32640, −31620 and so on. Every one of those is exactly −255 multiplied by the number of padding bytes in that image. It was the answer sitting there in plain sight (the 0xFF pad bytes decode to 255 each, and the stored checksum stops at the length field rather than the end of the file), and it was discarded because it wasn’t a constant. Lesson filed away: a residual that’s a clean multiple of something isn’t noise, it’s a clue.
The reference implementation is tiny:
import struct
def unpack(b):
assert b[:4] == bytes.fromhex("19941102") and (len(b) - 100) % 260 == 0
cks, ln = struct.unpack(">II", b[4:12])
k = (len(b) - 100) // 260
img = bytearray()
for i in range(k):
f = b[100 + 260*i : 100 + 260*(i + 1)]
assert struct.unpack(">H", f[:2])[0] == i # frame counter
chunk = bytes((x - 1) & 0xFF for x in f[2:258]) # +1 encoded data
assert struct.unpack(">H", f[258:])[0] == sum(chunk) # frame sum
img.extend(chunk)
assert sum(img[:ln]) == cks and set(img[ln:]) <= {0xFF} # global sum + pad
return b[:100], bytes(img)
def pack(header, img, ln):
k = (len(img) + 255) // 256
img = bytes(img).ljust(256 * k, b"\xFF")
out = bytearray(header)
struct.pack_into(">I", out, 4, sum(img[:ln]))
struct.pack_into(">I", out, 8, ln)
for i in range(k):
chunk = img[256*i : 256*i + 256]
out += struct.pack(">H", i)
out += bytes((x + 1) & 0xFF for x in chunk)
out += struct.pack(">H", sum(chunk))
return bytes(out)
I’ve got that wrapped up as a little beny_container.py tool with decode, repack and verify commands, plus a strict verifier that checks every counter, every frame sum, the global checksum and the padding across the whole corpus. 38 out of 38.
Interesting things found along the way
- The download check is real hashing, but it’s not a signature. From 1.2.6 onwards the firmware runs a genuine textbook SHA-256 (standard IV and round constants are sitting right there in flash) plus MD5 over the downloaded file, and compares them to digests that arrive in the OCPP download descriptor. That’s integrity, not authenticity. There’s no RSA, ECDSA or Ed25519 anywhere in the image and no embedded signing key. Trust is entirely anchored in the TLS connection to the OCPP server, which is done in the 4G modem.
- There’s dead AES in there. A cluster of GF(2^8) multiply functions that are unmistakably AES MixColumns arithmetic (coefficients 2, 3, 9, 11, 13, 14, polynomial 0x1B), with no S-box, no key schedule and no callers. Looks like a reference AES source file where the linker dead stripped everything except the leaf helpers. It protects nothing.
- The app never checks the container itself. The magic bytes appear nowhere in the application. It downloads the file, hashes it, writes it to flash in chunks and reboots, and the bootloader below
0x08020000does the magic and checksum validation. That bootloader isn’t in any downloadable image, so its exact logic is still unconfirmed, but given everything above it’s a safe bet it’s the same sum. - Checksums all the way down. The internal UART protocol (the same
55 AAframed one the communitybeny_wifiintegration speaks) uses an 8 bit additive sum. The flash key value store that holds config and calibration uses an additive sum. Whoever wrote this firmware likes addition and I respect the consistency. - Hardware bits. The energy metering is an HLW8032 (single phase, UART). The external meter for dynamic load balancing is Modbus RTU at 4800 or 9600 baud. DLB and solar telemetry goes back to the OCPP server as DataTransfer messages with vendor
*zjbenyand message IDdlbMeter, with seven operating modes (Normal, Solar, SolarIT, NewSolar, NewNormal, EED, P1) and per phase grid, solar, EVSE and home load figures. There’s no separate cloud telemetry at all. Everything rides OCPP. - There’s a hidden engineering console. A
#cmdcommand set, almost certainly on a debug UART:#cmd ft 4G|DLB|dry|eth|gnd|rfid|wififor factory tests,#cmd printf ento switch on debug logging on a release build,#cmd rcd ruici|shengxiangto pick which RCD sensor vendor is fitted,#cmd RCD Test, calibration via#cmd wvol|wvi|wgnd,#cmd oc lock|unlock,#cmd set url,#cmd netsafe on|off, and#cmd vendor/#cmd modelwhich set the strings the charger reports over OCPP. That last one finally explains why mine shows up in Home Assistant as vendor*zjbenymodel*evse: they’re factory placeholders nobody bothered to set. - The Wi-Fi access point password isn’t random. It’s derived from an MD5 hash truncated to 8 characters, with a hardcoded fallback of
12345678if that path fails. The admin web login is a user set password with no backdoor that I could find, which was a relief. - “DEBUG” builds aren’t debug builds. The
/DEBUG/tree on the server is the same code with about 500 extra trace strings for the UART log ([FTP_DEBUG]and friends). No symbols, no ELF, no DWARF. Every image is a raw binary. There is a developer signature string in the firmware reading “Author: TTMK, Company: BENY”, so hello TTMK if you ever read this.
The Ghidra side, briefly
With no symbols at all this was always going to be a slog. Headless Ghidra, ARM Cortex little endian, base address 0x08020000, and then a lot of staring. The embarrassing part is that the first full decompile was done on the interleaved stream from the “contiguous payload” assumption above, so it had four junk bytes every 260 and every function address was wrong by 4 * (offset / 256). Thumb code is dense enough that the disassembler mostly resynchronised at branch targets, so it looked fine and we’d named a couple of hundred functions before the container work revealed the problem. Once the clean image existed, mapping the old addresses across was a one liner, and a rebuild went from 1334 functions to 1600 (the junk had been merging things).
Naming was done with a multi agent setup: a cheap model proposes a name and purpose for each unnamed function given its decompiled body plus the names of whatever calls it and whatever it calls, and a more expensive model is told to reject anything that isn’t properly evidenced. Two rounds of that, with the second round benefiting from all the neighbours the first round named, got the project to 733 of 1600 functions with confident names, covering the charge session state machine, the OCPP request handlers and senders, the FTP and HTTP download path, the MD5 and SHA-256 implementations, the DLB current limit calculation, the ESP-AT parser and most of the FreeRTOS internals. The remaining 870 odd are mostly leaf helpers with no named neighbours to give them away.
So can you mod it?
Technically the file side is wide open. The container checksum is reproducible, so a modified image repacks into something that passes every check the transport and (almost certainly) the bootloader apply. There’s no signing to defeat. What stands in the way is more mundane: the charger will only download firmware from wherever the OCPP server tells it to over a TLS connection the modem has to trust, and the bootloader and the chip’s readout protection state are unknowns that would need an SWD probe on the board to settle. I haven’t flashed anything, I’m not going to publish a modified image, and I’d gently suggest nobody puts homebrew firmware on a device that switches 32 amps into their car. The point was to understand it, and now I do: it’s a competent, slightly old fashioned embedded design where the only cryptography that matters is the TLS in the modem, and the local control story is already solved by OCPP.
If you’ve got one of these chargers and you’ve found something I’ve missed, or you’ve got a 2M unit and want to compare notes on the encrypted container, there’s a contact link on the site.