1 - Reverse Engineering the TI EV2300
Contents
The Problem
A $200 USB Adapter Held Hostage by Proprietary DLLs
Texas Instruments makes the EV2300—a USB-to-I2C adapter for talking to their battery management ICs. It’s the standard tool for the BQ76920/BQ76940 evaluation boards. Plug it in, fire up bqStudio (TI’s official GUI), read some registers, write some registers. Simple.
Unless you want to do anything programmatic. Or use it on Linux. Or integrate it into a test automation script. Or do literally anything TI didn’t explicitly design the GUI for.
TI ships a stack of 32-bit Windows DLLs—bq80xrw.dll, bq80xusb.dll, CMAPI.dll—and a .NET application (bq76940.exe) that calls into them. There’s no documented API. There’s no SDK. There’s no Linux support. The existing open-source option (romixlab/ev2300 on GitHub) requires pyusb + libusb + Zadig, which replaces your Windows HID driver and breaks bqStudio. Oh, and you need admin privileges to run Zadig.
I had the EV2300 connected to a lab machine at school. No admin perms. No ability to install Zadig. No ability to install libusb. No ability to install anything, really. Just Python 3.12 and whatever ships with Windows 11.
So I reverse engineered the entire driver stack and replaced it with 400 lines of pure Python that uses only ctypes—zero third-party dependencies. It works on Windows through the native HID driver and on Linux through /dev/hidraw. No DLLs, no Zadig, no admin.
Understanding the DLL Stack
What TI Ships
Before bypassing the DLLs I needed to understand what they do. The EV2300 software lives in three places on this machine:
C:/Program Files (x86)/Texas Instruments/bq76940/— the old bq76940 evaluation tools (2013-era DLLs)C:/Program Files/TI/BatteryManagementStudio/— the newer bqStudio installation (2020-era DLLs, more features)lab5/vendor/bq76940/— vendored copies in my course repo
First I hashed everything to see which copies were identical and which were different:
import hashlib, pefile, os
from datetime import datetime, timezone
dlls = ['bq80xrw.dll', 'bq80xusb.dll', 'CMAPI.dll', 'bq80xSim.dll']
locations = {
'vendor': 'lab5/vendor/bq76940',
'system': 'C:/Program Files (x86)/Texas Instruments/bq76940',
'bqStudio': 'C:/Program Files/TI/BatteryManagementStudio',
}
for dll in dlls:
for label, path in locations.items():
fpath = os.path.join(path, dll)
if os.path.exists(fpath):
with open(fpath, 'rb') as f:
data = f.read()
h = hashlib.sha256(data).hexdigest()
print(f'{dll:16s} {label:10s} size={len(data):>7,} sha256={h[:16]}...')
bq80xrw.dll vendor size= 73,728 sha256=d0cf28264e6a0cbf...
bq80xrw.dll system size= 73,728 sha256=d0cf28264e6a0cbf... <- identical
bq80xrw.dll bqStudio size= 68,608 sha256=efa9d56c10c066ad... <- different, 2020 build
bq80xusb.dll vendor size= 51,200 sha256=d50830d42f9f02f0...
bq80xusb.dll system size= 51,200 sha256=d50830d42f9f02f0... <- identical
bq80xusb.dll bqStudio size= 51,200 sha256=11f6c453023d4f6c... <- same size, different build
CMAPI.dll vendor size= 57,856 sha256=9063aeee1373dd68...
CMAPI.dll system size= 57,856 sha256=9063aeee1373dd68... <- identical
CMAPI.dll bqStudio size= 96,768 sha256=3cf701ba130d3605... <- 67% larger
Vendor and system copies are byte-for-byte identical. The bqStudio copies are a 2020 rebuild with more features but same core API.
Then I dumped the actual exports. This is where it gets interesting:
import pefile
from datetime import datetime, timezone
pe = pefile.PE('lab5/vendor/bq76940/bq80xrw.dll')
ts = datetime.fromtimestamp(pe.FILE_HEADER.TimeDateStamp, tz=timezone.utc)
print(f'Architecture: x86 (32-bit)')
print(f'Build date: {ts.isoformat()}')
print(f'Exports:')
for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
name = exp.name.decode() if exp.name else f'ordinal_{exp.ordinal}'
print(f' {exp.ordinal:3d} 0x{exp.address:08x} {name}')
bq80xrw.dll — the main API layer (45 exports, built 2013-08-13):
1 0x0000a480 BlastPackets
2 0x00003290 CheckForError
3 0x00001fd0 CloseDeviceA
5 0x00004150 GPIORead
6 0x000046b0 GPIOSetup
7 0x000040c0 GPIOWrite
9 0x00003fa0 GetAdapterFWVersion
11 0x00002ac0 GetAllFreeBoards
12 0x000043b0 GetEV2300Name
16 0x00004cc0 HDQ8ReadBlock
17 0x00004e70 HDQ8WriteBlock
19 0x00002ea0 I2CPower
21 0x00003c30 I2CReadBlock
22 0x00003d90 I2CWriteBlock
23 0x00001fb0 OpenDeviceA
32 0x00002940 ReadSMBusBlock
33 0x00002590 ReadSMBusWord
43 0x00002780 WriteSMBusBlock
44 0x000031d0 WriteSMBusCmd
45 0x000026c0 WriteSMBusWord
(trimmed to the interesting ones—full list has SetPinVoltage, GetVVODVoltage, StartCommLogging, ProgramFlash, etc.)
bq80xusb.dll — the USB transport layer (25 exports, C++ mangled):
1 0x000029c0 ?CloseDevice@@YAHXZ
12 0x000019e0 ?GetPacket@@YAHHHPAD@Z
14 0x00001410 ?InitializePort@@YAHHHGGGGIII@Z
16 0x00001ac0 ?IsDeviceConnected@@YA_NXZ
19 0x00002960 ?OpenDevice@@YAHPBD@Z
22 0x000017b0 ?PutPacket@@YAHHHPAD@Z
24 0x000016a0 ?SetTimeout@@YAHHH@Z
Demangled, that’s OpenDevice(char const*), CloseDevice(), GetPacket(int,int,char*), PutPacket(int,int,char*). The raw HID read/write operations. Plus a bunch of board enumeration functions—GetFreeBoards, GetFreeBoardsEV2300, GetFreeBoardsEV2300A, GetFreeBoardsEV2400—because TI has multiple adapter variants and the DLL needs to tell them apart.
I also checked what bq80xrw.dll imports from bq80xusb.dll:
for entry in pe.DIRECTORY_ENTRY_IMPORT:
if b'bq80xusb' in entry.dll:
for imp in entry.imports:
print(f' {imp.name.decode()}')
?FlushTransmitBuffer@@YAHHH@Z
?GetSerialNumberString@@YAHPADF@Z
?GetFreeBoards@@YA_NHPADPAH@Z
?GetAllBoards@@YA_NHPADPAH@Z
?OpenDeviceBySerial@@YAHPAD@Z
?OpenDevice@@YAHPBD@Z
?CloseDevice@@YAHXZ
?IsDeviceConnected@@YA_NXZ
?PutPacket@@YAHHHPAD@Z
?GetPacket@@YAHHHPAD@Z
?InitializePort@@YAHHHGGGGIII@Z
?SetTimeout@@YAHHH@Z
12 functions. That’s the complete interface between the high-level API and the USB layer. Everything bq80xrw.dll does with the hardware goes through these 12 calls.
The bqStudio version of bq80xrw.dll has 60 exports (15 more than the vendor copy)—new stuff like SPI16ReadBlock, I2CLtReadBlock, HDQ8ReadByte, SetPWMConfig, UseAardvarkSPI. None of it relevant for basic SMBus register access, but good to know it exists.
| DLL | Exports | Build Date | Purpose |
|---|---|---|---|
bq80xrw.dll |
45 (vendor) / 60 (bqStudio) | 2013 / 2020 | High-level SMBus/I2C/HDQ/GPIO API |
bq80xusb.dll |
25 | 2013 / 2020 | USB HID transport (GetPacket/PutPacket) |
CMAPI.dll |
41 / 65 | 2013 / 2020 | Communication manager, SDK wrappers |
bq80xSim.dll |
21 | 2010 | Simulator (mirrors bq80xusb with _S suffix) |
The Call Chain
Here’s the part that matters. bq80xusb.dll imports from:
HID.DLL: HidD_GetHidGuid, HidD_GetAttributes,
HidD_SetNumInputBuffers, HidD_GetSerialNumberString
SETUPAPI.dll: SetupDiGetClassDevsA, SetupDiEnumDeviceInterfaces,
SetupDiGetDeviceInterfaceDetailA, SetupDiDestroyDeviceInfoList
KERNEL32.dll: CreateFileA, WriteFile, ReadFile, CloseHandle
That’s the standard Windows HID API. SetupAPI to enumerate devices, CreateFile to open them, WriteFile/ReadFile for 64-byte HID reports. The entire TI DLL stack is just a wrapper around the same Windows APIs that any program can call with ctypes.
So that’s what I did.
Building the Driver
The 64-bit Handle Trap
First attempt: define the Windows structures, bind the DLL functions, call SetupDiGetClassDevsA, iterate devices. Zero devices found. No error. Just… nothing there.
Here’s what I had initially. The structures are straight translations of the Windows SDK headers:
import ctypes
import ctypes.wintypes as wt
class GUID(ctypes.Structure):
_fields_ = [
("Data1", ctypes.c_ulong), ("Data2", ctypes.c_ushort),
("Data3", ctypes.c_ushort), ("Data4", ctypes.c_ubyte * 8)]
class SP_DEVICE_INTERFACE_DATA(ctypes.Structure):
_fields_ = [
("cbSize", wt.DWORD), ("InterfaceClassGuid", GUID),
("Flags", wt.DWORD), ("Reserved", ctypes.POINTER(ctypes.c_ulong))]
class SP_DEVICE_INTERFACE_DETAIL_DATA_A(ctypes.Structure):
_fields_ = [("cbSize", wt.DWORD), ("DevicePath", ctypes.c_char * 512)]
hid = ctypes.windll.hid
setupapi = ctypes.windll.setupapi
k32 = ctypes.windll.kernel32
Standard stuff. Get the HID GUID, call SetupDiGetClassDevs, iterate with SetupDiEnumDeviceInterfaces. Here’s the full enumeration loop—this is the part that finds the device path and reads the VID/PID:
class HIDD_ATTRIBUTES(ctypes.Structure):
_fields_ = [
("Size", wt.ULONG), ("VendorID", ctypes.c_ushort),
("ProductID", ctypes.c_ushort), ("VersionNumber", ctypes.c_ushort)]
guid = GUID()
hid.HidD_GetHidGuid(ctypes.byref(guid))
h_info = setupapi.SetupDiGetClassDevsA(
ctypes.byref(guid), None, None, 0x12) # DIGCF_PRESENT | DIGCF_DEVICEINTERFACE
idx = 0
while True:
iface = SP_DEVICE_INTERFACE_DATA()
iface.cbSize = ctypes.sizeof(SP_DEVICE_INTERFACE_DATA)
if not setupapi.SetupDiEnumDeviceInterfaces(
h_info, None, ctypes.byref(guid), idx, ctypes.byref(iface)):
break # no more devices
# Get the device path string
detail = SP_DEVICE_INTERFACE_DETAIL_DATA_A()
detail.cbSize = 5 # <-- wrong on 64-bit, should be 8
req = wt.DWORD()
setupapi.SetupDiGetDeviceInterfaceDetailA(
h_info, ctypes.byref(iface),
ctypes.byref(detail), ctypes.sizeof(detail),
ctypes.byref(req), None)
path = detail.DevicePath # e.g. b'\\\\?\\hid#vid_0451&pid_0036#6&2cb02d24&...'
# Open the device briefly just to read its VID/PID
h_dev = k32.CreateFileA(path, 0, 0x03, None, 3, 0, None) # no R/W access needed
attrs = HIDD_ATTRIBUTES()
attrs.Size = ctypes.sizeof(HIDD_ATTRIBUTES)
hid.HidD_GetAttributes(h_dev, ctypes.byref(attrs))
print(f'[{idx}] VID=0x{attrs.VendorID:04x} PID=0x{attrs.ProductID:04x} {path}')
if attrs.VendorID == 0x0451 and attrs.ProductID == 0x0036:
ev2300_path = path # save it for later
k32.CloseHandle(h_dev)
idx += 1
setupapi.SetupDiDestroyDeviceInfoList(h_info)
Ran it. SetupDiEnumDeviceInterfaces returned FALSE on the very first call. GetLastError() = 6, which is ERROR_INVALID_HANDLE. The handle from SetupDiGetClassDevsA looked fine—non-null, non-INVALID_HANDLE_VALUE. But it was garbage.
Spent 20 minutes before it clicked. ctypes defaults function return types to c_int—32 bits. SetupDiGetClassDevsA returns HDEVINFO which is void*—8 bytes on 64-bit Windows. The OS handed back a valid 64-bit pointer like 0x0000026d65e4a9b0 and ctypes truncated it to 0x65e4a9b0. That truncated value isn’t a valid handle. Hence error 6.
The fix is setting .restype on every function that returns a handle:
setupapi.SetupDiGetClassDevsA.restype = ctypes.c_void_p
setupapi.SetupDiGetClassDevsA.argtypes = [
ctypes.POINTER(GUID), ctypes.c_char_p, ctypes.c_void_p, wt.DWORD]
setupapi.SetupDiEnumDeviceInterfaces.restype = wt.BOOL
setupapi.SetupDiEnumDeviceInterfaces.argtypes = [
ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(GUID),
wt.DWORD, ctypes.POINTER(SP_DEVICE_INTERFACE_DATA)]
k32.CreateFileA.restype = ctypes.c_void_p
k32.CreateFileA.argtypes = [
ctypes.c_char_p, wt.DWORD, wt.DWORD, ctypes.c_void_p,
wt.DWORD, wt.DWORD, ctypes.c_void_p]
k32.WriteFile.restype = wt.BOOL
k32.WriteFile.argtypes = [
ctypes.c_void_p, ctypes.c_void_p, wt.DWORD,
ctypes.POINTER(wt.DWORD), ctypes.c_void_p]
k32.ReadFile.restype = wt.BOOL
k32.ReadFile.argtypes = [
ctypes.c_void_p, ctypes.c_void_p, wt.DWORD,
ctypes.POINTER(wt.DWORD), ctypes.c_void_p]
And cbSize for SP_DEVICE_INTERFACE_DETAIL_DATA_A must be 8 on 64-bit, not 5:
detail.cbSize = 8 if ctypes.sizeof(ctypes.c_void_p) == 8 else 5
If you ever do Windows HID from Python ctypes on a 64-bit system and get ERROR_INVALID_HANDLE for no obvious reason, this is probably why.
First Contact
After fixing the handle types, the enumeration worked immediately:
Found EV2300: \\?\hid#vid_0451&pid_0036#6&2cb02d24&0&0000#{...}
VID=0x0451 PID=0x0036 Ver=0x0002
Usage=0x00a5 UsagePage=0xffa0
InputReportLen=65
OutputReportLen=65
Serial: 'HPA02 FW:2.0a/TUSB3210:0'
Product: 'EV2300A'
Manufacturer: 'Texas Inst'
Eight HID devices on the system, one from TI. Usage page 0xFFA0 is vendor-defined, usage 0x00A5 is specific to the EV2300. Reports are 65 bytes—1 byte report ID (always 0x00) plus 64 bytes of actual data. On Linux the report ID is stripped by the kernel so you just read()/write() 64 bytes directly to /dev/hidraw.
Sending the First Packet
At this point I could enumerate and read device strings, but that’s all read-only HID descriptor stuff—it doesn’t actually talk to the EV2300’s firmware. To do that I needed to open the device for read/write and start sending HID output reports.
The device path from enumeration is a byte string like \\?\hid#vid_0451&pid_0036#6&2cb02d24&0&0000#{...}. You pass that straight to CreateFileA:
GENERIC_READ = 0x80000000
GENERIC_WRITE = 0x40000000
FILE_SHARE_RW = 0x03
OPEN_EXISTING = 3
handle = k32.CreateFileA(
ev2300_path,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_RW,
None, OPEN_EXISTING, 0, None)
# Increase the HID input buffer so we don't drop reports
hid.HidD_SetNumInputBuffers(handle, 64)
Now I could write 65-byte output reports and read 65-byte input reports. But what do I put in those 65 bytes? The HID reports are just a transport—the actual protocol is a framed format inside the 64 data bytes. I knew the rough structure from romixlab/ev2300, but several things were unverified. Time to verify them against real hardware.
I built a READ_WORD packet by hand—command 0x01, targeting I2C address 0x08 (the BQ76920’s default), register 0x00—and sent it:
# Build a READ_WORD packet manually
buf = bytearray(64)
buf[0] = 10 # length
buf[1] = 0xAA # frame marker
buf[2] = 0x01 # READ_WORD command
buf[6] = 2 # payload length
buf[7] = 0x08 << 1 # I2C address, left-shifted (0x10)
buf[8] = 0x00 # register address (SYS_STAT)
buf[9] = crc8(buf[2:9]) # CRC-8 over the command through last payload byte
buf[10] = 0x55 # frame end
# Send it (prepend report ID 0x00 for Windows HID)
report_out = bytes([0x00]) + bytes(buf) # 65 bytes total
written = wt.DWORD()
k32.WriteFile(handle, report_out, 65, ctypes.byref(written), None)
# Read the response
report_in = ctypes.create_string_buffer(65)
read_n = wt.DWORD()
k32.ReadFile(handle, report_in, 65, ctypes.byref(read_n), None)
data = report_in.raw[1:read_n.value] # strip report ID
print(f'TX: {" ".join(f"{b:02x}" for b in buf[:12])}')
print(f'RX: {" ".join(f"{b:02x}" for b in data[:14])}')
print(f'Response cmd: 0x{data[2]:02x}')
TX: 0a aa 01 00 00 00 02 10 00 5e 55 00
RX: 0c aa 41 00 00 01 04 00 00 f8 08 13 55 01
Response cmd: 0x41
0x41. That’s 0x01 | 0x40. The EV2300 responded. No DUT connected—no BQ76920 on the I2C bus—but the adapter accepted the command, tried to read from the bus, got noise from the floating lines (0xF800), and packaged it into a valid response frame with a CRC. The protocol works.
That first successful round-trip was the moment I knew the entire TI DLL stack was unnecessary. Everything after this was just mapping out the details.
The HID Protocol
Once I had the first packet working, I cleaned up the packet building into a proper function and started systematically testing every command code.
Here’s the packet builder:
@staticmethod
def build_packet(cmd, i2c_addr=0, reg=0, data=b""):
buf = bytearray(64)
if cmd == 0x80: # SUBMIT packet is special
buf[0] = 8
buf[1] = 0xAA
buf[2] = 0x80
buf[6] = 0
buf[7] = crc8(buf[2:7])
buf[8] = 0x55
return buf
payload = bytearray()
payload.append(i2c_addr << 1) # I2C address is left-shifted
payload.append(reg)
payload.extend(data)
plen = len(payload)
buf[0] = 2 + 1 + 3 + 1 + plen + 1 + 1 # total length
buf[1] = 0xAA # frame marker
buf[2] = cmd # command code
# [3:6] reserved, already zero
buf[6] = plen # payload length
buf[7:7 + plen] = payload # I2C addr + register + data
crc_end = 7 + plen
buf[crc_end] = crc8(buf[2:crc_end]) # CRC over cmd through last data byte
buf[crc_end + 1] = 0x55 # frame end
return buf
A concrete example—READ_WORD from register 0x00 at I2C address 0x08:
build_packet(0x01, i2c_addr=0x08, reg=0x00)
TX: 0a aa 01 00 00 00 02 10 00 5e 55 00 00 00 ... (zero-padded to 64)
| | | | | | | |
| | | | | | | +-- frame end
| | | | | | +-- CRC-8 over bytes [2:9]
| | | | | +-- register 0x00
| | | | +-- I2C addr (0x08 << 1 = 0x10)
| | | +-- payload length = 2
| | +-- command 0x01 (READ_WORD)
| +-- frame marker 0xAA
+-- total length = 10 bytes
And the response that came back from the real device (no DUT, floating I2C bus):
RX: 0c aa 41 00 00 01 04 00 00 f8 08 13 55 01 00 00 ...
| | | | | | | | | |
| | | | | | | | | +-- (trailing buffer data)
| | | | | | | | +-- frame end
| | | | | | | +-- CRC-8
| | | | | | +-- extra byte (bus noise)
| | | | | +-- value high byte (0xF8)
| | | | +-- value low byte (0x00) -> word = 0xF800
| | | +-- payload length = 4
| | +-- response cmd 0x41 = 0x01 | 0x40 (success!)
| +-- frame marker 0xAA
+-- total length = 12
Response command 0x41 = request 0x01 OR’d with 0x40. That’s the success flag. The device read from a floating I2C bus with no target and got 0xF800—bus noise, but the protocol worked perfectly.
Write operations require a two-packet handshake. You send the write command, then a SUBMIT packet (0x80), then read one response:
TX1: 0c aa 04 00 00 00 04 10 00 34 12 [crc] 55 <- WRITE_WORD: reg 0x00 = 0x1234
TX2: 08 aa 80 00 00 00 00 [crc] 55 <- SUBMIT handshake
RX: ... 44 ... <- response 0x44 = success
CRC Validation: The Device Actually Checks
I wasn’t sure if the EV2300 firmware validated the CRC or just ignored it. Easy to test—send a packet with a deliberately corrupted CRC:
Good CRC: resp=0x41 (success)
Bad CRC: resp=0x46 (error)
-> Device REJECTS bad CRC
Good. The firmware validates. This means our CRC implementation has to be correct or nothing works. Here’s the pure Python version:
_CRC8_TABLE: list[int] = []
for _i in range(256):
_c = _i
for _ in range(8):
_c = ((_c << 1) ^ 0x07) & 0xFF if _c & 0x80 else (_c << 1) & 0xFF
_CRC8_TABLE.append(_c)
def crc8(data: bytes | bytearray) -> int:
crc = 0x00
for b in data:
crc = _CRC8_TABLE[crc ^ b]
return crc
No external library needed. The crc8 pip package does the same thing with more overhead.
Protocol Verification
Probing Every Command Code
The romixlab code documents seven command codes. Some were confirmed, some were “inferred from pattern analysis.” I wrote a protocol test suite using overlapped I/O (so unresponsive commands time out instead of blocking forever) and sent every command from 0x01 to 0x7F to the EV2300.
The EV2300 wasn’t connected to any DUT—no BQ76920, just the adapter sitting on the bench with nothing on the I2C bus. Read commands that try to talk to a nonexistent device will either get bus noise back or NACK. Both are fine for protocol verification—I just need to see if the EV2300 accepts the command, not whether the target responds.
Confirmed Commands
| Code | Name | Response | Status |
|---|---|---|---|
0x01 |
READ_WORD | 0x41 (cmd|0x40) |
Confirmed — returned data from floating bus |
0x02 |
READ_BLOCK | 0x46 (error) |
Confirmed — NACK without DUT, protocol works |
0x04 |
WRITE_WORD | 0x46 (error) |
Confirmed — NACK on write attempt |
0x05 |
WRITE_BLOCK | 0x46 (error) |
Confirmed — same |
0x06 |
COMMAND | 0xC0 |
Confirmed — Send Byte accepted |
Response Pattern Discovery
The response command code for a successful operation is request command OR’d with 0x40:
Send 0x01 (READ_WORD) -> Receive 0x41
Send 0x04 (WRITE_WORD) -> Receive 0x44
Send 0x80 (SUBMIT) -> Receive 0xC0
Error (any command) -> Receive 0x46
0x46 is the universal error code. Slightly annoying because 0x06 | 0x40 = 0x46, making COMMAND’s success response identical to the error code. You differentiate by checking the CRC.
Inferred Commands
0x03 (READ_BYTE) and 0x07 (WRITE_BYTE) are from the romixlab source and follow the pattern logically, but I couldn’t get clean isolated responses for them during rapid-fire probing. The response buffer on the EV2300 has some timing quirks—when you fire commands back-to-back, responses occasionally lag by one. In normal operation (send, wait, read, process) this isn’t a problem. During the protocol scan it made correlating specific responses to specific commands harder for some codes.
Undocumented Commands
Out of 118 command codes scanned:
- 87 produced no response (timeout)
- 12 returned error (
0x46) - 20 returned something
Most of the “something” responses were internal firmware status queries or echoes. One stood out:
Command 0x70 returned 170 bytes of payload starting with:
09 02 19 00 01 01 00 80 32 09 04 00 00 01 ff 00 00 00 07 05 01 02 40 00 00
That’s a USB configuration descriptor. 0x09 0x02 is the config descriptor header, 0x09 0x04 is an interface descriptor, 0x07 0x05 is an endpoint descriptor. The EV2300 has a debug command that dumps its own USB descriptors. Neat, totally useless for I2C communication, but neat.
Cross-Platform Support
Windows: ctypes All the Way Down
On Windows the write and read functions prepend/strip a report ID byte. The EV2300 uses report ID 0x00 (the only one), so every output report is 0x00 + 64 bytes of packet data = 65 bytes total. Input reports come back as 65 bytes and we strip the first byte:
def write(self, handle, data: bytes) -> bool:
report = b"\x00" + bytes(data[:64])
report += b"\x00" * (65 - len(report))
written = wt.DWORD()
ok = k32.WriteFile(handle, report, len(report), ctypes.byref(written), None)
return bool(ok)
def read(self, handle) -> bytes | None:
buf = ctypes.create_string_buffer(65)
read_n = wt.DWORD()
ok = k32.ReadFile(handle, buf, 65, ctypes.byref(read_n), None)
if not ok:
return None
raw = buf.raw[:read_n.value]
return raw[1:] if len(raw) > 1 else None # strip report ID
The enumeration walks the SetupAPI device list, opens each HID device briefly to check VID/PID via HidD_GetAttributes, and matches against 0x0451:0x0036.
Linux: /dev/hidraw Is Beautiful
On Linux the kernel’s hidraw driver exposes HID devices as /dev/hidraw0, /dev/hidraw1, etc. You enumerate by scanning /sys/class/hidraw/*/device/ for the VID/PID, then just open() the device file and read()/write() raw 64-byte packets. No report ID prefix. No special API. It’s a file.
def write(self, handle, data: bytes) -> bool:
report = bytes(data[:64])
report += b"\x00" * (64 - len(report))
written = os.write(handle, report)
return written == 64
def read(self, handle) -> bytes | None:
data = os.read(handle, 64)
return data if data else None
That’s it. The entire Linux transport layer is os.open, os.read, os.write, os.close, plus some ioctl calls for device info and sysfs reads for VID/PID matching.
The only catch is permissions. By default /dev/hidraw* is root-only. One udev rule fixes it:
# /etc/udev/rules.d/99-ev2300.rules
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0451", ATTRS{idProduct}=="0036", MODE="0666"
Then sudo udevadm control --reload-rules && sudo udevadm trigger and you’re done. No Zadig, no libusb, no driver replacement.
Backend Auto-Selection
The driver auto-detects the platform and picks the right backend:
def _get_backend():
if sys.platform == "win32":
return _WindowsBackend()
elif sys.platform.startswith("linux"):
return _LinuxBackend()
else:
raise OSError(f"Unsupported platform: {sys.platform}")
Same API, same packet format, same protocol layer. Only the HID transport differs.
The Final Driver
What It Does
ev2300_hid.py is a single file, ~600 lines, zero dependencies beyond the Python standard library. It provides:
- HID enumeration and auto-discovery — finds the EV2300 by VID/PID
- Full SMBus operations —
read_word,write_word,read_byte,write_byte,read_block,write_block,send_byte - Device info — serial, product, manufacturer strings
- CRC-8 — pure Python, matches the EV2300 firmware’s validator
- SMBusTransport compatibility — drop-in replacement for the existing DLL-based transport
- Raw command probing —
probe_command()for reverse engineering
Usage:
from ev2300_hid import EV2300HID
ev = EV2300HID()
ev.open()
info = ev.get_device_info()
print(info["product"]) # "EV2300A"
print(info["serial"]) # "HPA02 FW:2.0a/TUSB3210:0"
# Read SYS_STAT register from BQ76920 at I2C address 0x08
result = ev.read_word(0x08, 0x00)
if result["ok"]:
print(f"SYS_STAT = 0x{result['value']:04x}")
ev.close()
No DLLs loaded. No driver replacement. No admin needed on Windows. One udev rule on Linux.
Wrap-Up
The TI DLL stack—bq80xrw.dll, bq80xusb.dll, CMAPI.dll—is ultimately just a wrapper around the Windows HID API. The EV2300 speaks a straightforward framed protocol over 64-byte HID reports with CRC-8 validation. Once you know the packet format and the command codes, you can talk to it from any language on any platform that can open a HID device.
The protocol test also turned up 20 undocumented firmware commands, a USB descriptor dump at 0x70, and confirmed that the response pattern is cmd | 0x40 for success and 0x46 for error. Most of the undocumented stuff is internal firmware status queries that aren’t useful for normal I2C operations, but they’re documented in the protocol results JSON in case someone wants to dig deeper.
Next step is turning this into a C library. The protocol is fully mapped—packet format, CRC algorithm, command codes, submit handshake, error handling. The C implementation will use the same Windows HID API on Windows and hidraw on Linux, just without the ctypes overhead.
All scripts, source code, and documentation for this project are at github.com/bsikar/eset-453.