2 - Reverse Engineering the TI EV2300: Building the Firmware Clone
Contents
“started from the bottom now we’re here” — Drake
From Python Driver to Firmware Clone
In Part 1 I reverse engineered the EV2300’s HID protocol and built a pure-Python driver that talks to the real adapter without TI’s DLLs. That solved the “no scripting interface” problem—but we still needed the physical EV2300 hardware. At $200 a pop, with a supply chain that’s been spotty since 2020, that’s not great for a university lab with 30 students.
So the next step was obvious: build firmware that makes a $25 Adafruit Feather STM32F405 pretend to be an EV2300. Same VID/PID, same HID protocol, same packet format. Plug it into bqStudio, bqStudio thinks it’s talking to a real EV2300, everybody’s happy.
This is where Cesar came in. I had the protocol mapped out from the Python work—packet format, CRC algorithm, command codes, response patterns. Cesar took all of that and built the actual STM32 firmware from scratch: the CubeMX project, the USB HID class modifications (turning STM32’s stock mouse HID into a vendor-defined bidirectional interface), the I2C bridge logic, and the BQ76920 driver with automatic CRC/non-CRC mode detection. He also set up the CI/CD pipeline and the initial build system.
It took about 20 protocol bugs between us to get everything working.
The Initial Implementation
Turning a Mouse into an Adapter
The STM32 HAL’s USB HID class ships configured as a mouse. Report descriptor says “I’m a boot mouse, I send 4-byte reports about cursor movement.” That’s not going to work.
Cesar ripped out the mouse descriptor and replaced it with a vendor-defined HID interface matching the real EV2300:
/* Vendor-defined HID report descriptor: 64-byte IN + 64-byte OUT */
0x06, 0xA0, 0xFF, /* Usage Page (Vendor Defined 0xFFA0) */
0x09, 0xA5, /* Usage (0x00A5) */
0xA1, 0x01, /* Collection (Application) */
0x09, 0x01, 0x15, 0x00, 0x26, 0xFF, 0x00,
0x75, 0x08, 0x95, 0x40, 0x81, 0x02, /* Input: 64 bytes */
0x09, 0x01, 0x15, 0x00, 0x26, 0xFF, 0x00,
0x75, 0x08, 0x95, 0x40, 0x91, 0x02, /* Output: 64 bytes */
0xC0
Usage page 0xFFA0, usage 0xA5—from the real EV2300’s descriptor dump in Part 1. He also added the OUT endpoint to the configuration descriptor (stock STM32 HID only has IN), set up the DataOut callback with a function pointer for the bridge logic, and matched the USB descriptors:
#define USBD_VID 0x0451 /* Texas Instruments */
#define USBD_PID_FS 0x0036 /* EV2300A */
The serial string had to match exactly: HPA02 FW:2.0a/TUSB3210:0. TI’s DLLs parse this string to identify EV2300A adapters. Cesar also matched the manufacturer string (Texas Inst—10 chars, the real EV2300 truncates it) and bcdDevice (0x0002).
The Bridge Architecture
The firmware is conceptually simple. USB HID report comes in on the OUT endpoint, parse it, do an I2C transaction, build a response, send it on the IN endpoint:
bqStudio -> USB HID OUT -> STM32 parses frame -> I2C read/write -> build response -> USB HID IN -> bqStudio
Cesar implemented the full EV2300 frame protocol—0xAA marker, CRC-8, 0x55 end marker—plus the two-phase write handshake (buffer on WRITE, execute on SUBMIT). He also wrote the BQ76920 I2C driver with automatic address detection: the BQ76920 can operate at address 0x08 (no CRC) or 0x18 (CRC enabled), and his driver probes both on startup and routes reads/writes through the appropriate CRC path.
First build had READ_WORD, READ_BYTE, READ_BLOCK, WRITE_WORD, WRITE_BYTE, and the SUBMIT handshake all implemented. Plugged it in, opened bqStudio. “Communication adapter not found.”
The Dual-Device Comparison
Sniffing the Real EV2300
The Python driver worked with the firmware. bqStudio didn’t. Same VID/PID, same protocol. What’s different?
I wrote diff_ev2300_responses.py—a script that opens BOTH the real EV2300 and the STM32 simultaneously (they can coexist if you match by serial number), sends the same command to each, and compares the raw response bytes. Every command from 0x00 to 0x7F, plus parameterized reads at different I2C addresses and registers.
124 command/response pairs captured. 108 mismatched.
108 out of 124.
The Seven Deadly Bugs
The diff results were brutal. I documented every mismatch with hex packet comparisons and handed them to Cesar. Here are the ones that mattered:
Bug 1: totalLen off by one. Every single response had the wrong length byte. Formula was plen + 9 instead of plen + 8. Off by one on every packet.
Bug 2: READ_WORD payload completely wrong. Firmware was sending {i2c_addr_8bit, data_lo, data_hi} with plen=3. The real EV2300 sends {register, data_lo, data_hi, i2c_addr_7bit} with plen=4. Wrong byte count, wrong byte order, wrong address format.
Bug 3: WRITE_BYTE must not respond. Firmware was sending an ack packet for WRITE_BYTE (0x07). The real EV2300 sends nothing. The host times out on the read, considers that normal, and sends SUBMIT. If you respond, you desync the packet stream.
Bug 4: SUBMIT always succeeds. Firmware returned error when the I2C write failed. The real EV2300 always returns 0xC0 with payload {0x33, 0x31, 0x6D} regardless of whether the target device ACK’d. The DLLs detect write failures by reading back the register, not by checking the SUBMIT response.
Bug 5: Error responses need payloads. Error responses were just 0x46 with no payload. The real EV2300 always includes a 2-byte error payload—{0x00, 0x93} for I2C NACK, {0x55, 0x93} for adapter-level errors. The DLLs parse these payloads.
Bug 6: Reserved byte [5] is 0x01, not 0x00. Three bytes of “reserved” space in the frame. All zero’d. The real EV2300 puts 0x01 in byte [5]. The DLLs check this.
Bug 7: Undocumented commands need specific responses. The real EV2300 responds to about 20 commands beyond the documented seven. Some return status data, some return error. Most undocumented commands get no response at all—the host times out. If you respond to a command the DLLs expect silence on, you poison the HID input buffer.
Cesar fixed all seven in the firmware. We re-ran the dual-device diff. Matches went from 16/124 to 89/124. bqStudio connected. It could read registers. Progress.
But it still couldn’t write.
The Silence Problem
“Found a non-error packet when expecting an error packet”
bqStudio’s error dialog when trying to write a byte:
“Error writing byte. Please check communication with device. Error message returned from adapter was Found a non-error packet when expecting an error packet.”
And when trying to clear faults:
“Unable to read SYS_STAT register after attempt to clear bits. Please check that device is connected to adapter and communicating.” Status bar: “EV2X00 Adapter USB Timeout.”
I captured the firmware’s command log during these failures. The GUI sends ExtendedWrite (0x1E) for writes and ExtendedRead (0x1D) for reads—parameterized versions of the basic commands. The basic 0x01/0x07 commands are used by the DLLs; the GUI uses 0x1D/0x1E.
To figure out what the real EV2300 actually does for these commands, I plugged in the real hardware and ran flushed captures—sending each command with a full HID queue drain between tests so there’s no stale data contamination. The results:
The real EV2300 is silent for 0x1E (ExtendedWrite). No response at all. The firmware was sending a 0x46 error response. That stale response sat in the HID input buffer. When the GUI sent the next 0x1D (ExtendedRead), it read the stale 0x46 instead of the read response. Hence “found a non-error packet when expecting an error packet.”
Same problem with I2CPower (0x18). The DLLs send 0x18 to enable the I2C power rail. The real EV2300 processes it silently. The firmware responded with 0x46. The DLL read that stale 0x46 as the response to the next ReadSMBusWord, got confused, reported NACK.
Three commands that must be silent—no HID response at all:
| Command | Code | Real EV2300 | Firmware (broken) |
|---|---|---|---|
| I2CPower | 0x18 | TIMEOUT (silent) | Sent 0x46 error |
| WriteByte | 0x07 | TIMEOUT (silent) | Sent 0x46 error |
| ExtendedWrite | 0x1E | TIMEOUT (silent) | Sent 0x46 error |
I confirmed this with flushed captures from the real EV2300 and fixed the firmware—deleted the response-building code from all three handlers. I also added the ExtendedRead (0x1D) and ExtendedWrite (0x1E) handlers that Cesar’s initial implementation didn’t have, plus address normalization (the GUI sends 7-bit addresses like 0x08 but the STM32 HAL needs 8-bit addresses like 0x10).
After this fix, bqStudio could read registers. Writes still showed “USB Timeout” in the DLL path, but that’s a different problem. One I’d spend the next several hours on.
Continued in Part 3: The DLL Battle—where Ghidra reveals why the DLL can’t read our responses, and two bytes of CRC change everything.
Source code: github.com/CesMag/BQ76920_Bridge
Python driver: github.com/T-O-M-Tool-Oauto-Mationator/scpi-instrument-toolkit (lab_instruments/src/ev2300.py)