rm3100/ch347.py
2026-08-19 23:00:47 -04:00

165 lines
5.7 KiB
Python

"""I2C master over a CH347 USB adapter (Waveshare USB to UART/I2C/SPI/JTAG).
Knows nothing about any particular I2C device -- it only moves bytes to and
from a 7-bit address.
The CH347 in Mode 1 (UART1+I2C+SPI) presents three interfaces: two CDC-ACM
interfaces that the kernel binds for the UART, and interface 2, a vendor-class
interface carrying I2C/SPI/JTAG that no kernel driver claims. We talk to
interface 2 directly over libusb.
Framing follows the CH347 stream protocol as implemented by the
aystarik/ch347-i2c-spi-gpio Linux driver, whose USB id table entry
USB_DEVICE_INTERFACE_NUMBER(0x1a86, 0x55db, 0x02) matches this device.
"""
import usb.core
import usb.util
VENDOR_ID = 0x1A86
PRODUCT_ID = 0x55DB
INTERFACE = 2
EP_OUT = 0x06
EP_IN = 0x86
TIMEOUT_MS = 1000
# Stream protocol opcodes.
CMD_STREAM = 0xAA # start of an I2C command stream
CMD_END = 0x00 # end of stream
CMD_STA = 0x74 # emit START
CMD_STO = 0x75 # emit STOP
CMD_OUT = 0x80 # write; low 6 bits are the byte count
CMD_IN = 0xC0 # read; low 6 bits are the byte count
CMD_SET = 0x60 # set bus speed; low bits select the rate
# Bus speeds, as the low nibble of CMD_SET.
SPEED_20KHZ = 0
SPEED_100KHZ = 1
SPEED_400KHZ = 2
SPEED_750KHZ = 3
# The count field is 6 bits, and a write also spends one byte on the address.
MAX_XFER = 0x3F
ACK = 1
class CH347I2C:
"""I2C master on a CH347 adapter.
Every byte the adapter clocks out produces one status byte in the reply,
where 1 means the slave ACKed. A read reply is one address-ACK byte
followed by the payload.
"""
def __init__(self, speed=SPEED_100KHZ):
self._dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)
if self._dev is None:
raise IOError(
f"No CH347 adapter found ({VENDOR_ID:04x}:{PRODUCT_ID:04x}). "
"Is it plugged in and in Mode 1?"
)
# Deliberately no set_configuration(): the device is already configured,
# and re-setting it would reset the CDC-ACM interfaces the kernel is
# using for /dev/ttyACM*.
try:
usb.util.claim_interface(self._dev, INTERFACE)
except usb.core.USBError as exc:
raise IOError(
f"Cannot claim CH347 interface {INTERFACE}: {exc}. "
"If this is a permission error, run ./setup.sh."
) from exc
self._claimed = True
self.set_speed(speed)
def close(self):
if getattr(self, "_claimed", False):
usb.util.release_interface(self._dev, INTERFACE)
usb.util.dispose_resources(self._dev)
self._claimed = False
def __enter__(self):
return self
def __exit__(self, *exc_info):
self.close()
return False
def _xfer(self, out_bytes, in_len):
"""Send one command stream, then read in_len status/data bytes."""
written = self._dev.write(EP_OUT, bytes(out_bytes), TIMEOUT_MS)
if written != len(out_bytes):
raise IOError(
f"Short USB write to CH347: sent {written} of {len(out_bytes)} bytes"
)
if in_len == 0:
return b""
reply = bytes(self._dev.read(EP_IN, in_len, TIMEOUT_MS))
if len(reply) != in_len:
raise IOError(
f"Short USB read from CH347: got {len(reply)} of {in_len} bytes"
)
return reply
def set_speed(self, speed):
"""Select the I2C clock rate (one of the SPEED_* constants)."""
if speed not in (SPEED_20KHZ, SPEED_100KHZ, SPEED_400KHZ, SPEED_750KHZ):
raise ValueError(f"Invalid I2C speed {speed}, expected 0-3")
self._xfer([CMD_STREAM, CMD_SET | speed, CMD_END], 0)
def write(self, addr, data):
"""Write data (bytes) to a 7-bit address. Raises IOError on NACK."""
data = bytes(data)
# One byte of the transfer budget goes to the address.
if len(data) > MAX_XFER - 1:
raise ValueError(
f"Write of {len(data)} bytes exceeds the CH347 limit of {MAX_XFER - 1}"
)
packet = [CMD_STREAM, CMD_STA, CMD_OUT | (len(data) + 1), addr << 1]
packet += data
packet += [CMD_STO, CMD_END]
# One status byte per clocked-out byte: the address plus the payload.
reply = self._xfer(packet, len(data) + 1)
if reply[0] != ACK:
raise IOError(f"No ACK from I2C address 0x{addr:02x} on write")
if any(byte != ACK for byte in reply[1:]):
raise IOError(
f"I2C address 0x{addr:02x} NACKed a data byte "
f"(status {reply.hex(' ')})"
)
def read(self, addr, count):
"""Read count bytes from a 7-bit address. Raises IOError on NACK."""
if not 1 <= count <= MAX_XFER:
raise ValueError(f"Read of {count} bytes outside 1..{MAX_XFER}")
packet = [CMD_STREAM, CMD_STA, CMD_OUT | 1, (addr << 1) | 1]
# All but the last byte are ACKed by us; the final bare CMD_IN NACKs to
# tell the slave to stop.
if count > 1:
packet.append(CMD_IN | (count - 1))
packet += [CMD_IN, CMD_STO, CMD_END]
reply = self._xfer(packet, count + 1)
if reply[0] != ACK:
raise IOError(f"No ACK from I2C address 0x{addr:02x} on read")
return reply[1:]
def probe(self, addr):
"""Return True if a device ACKs its address. A NACK is not an error."""
packet = [CMD_STREAM, CMD_STA, CMD_OUT | 1, addr << 1, CMD_STO, CMD_END]
try:
return self._xfer(packet, 1)[0] == ACK
except IOError:
return False
def scan(self, first=0x08, last=0x77):
"""Return the addresses in [first, last] that respond."""
return [addr for addr in range(first, last + 1) if self.probe(addr)]