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 operationsread_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 probingprobe_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.