CircuitsFT232H

Hex.pmLicense: Apache-2.0

Use an Adafruit FT232H breakout board (or any USB-attached FT232H) as an I2C master, SPI master, or GPIO controller from your host machine via the circuits_i2c, circuits_spi, and circuits_gpio APIs.

This lets you develop and test I2C/SPI/GPIO device drivers on your laptop with real hardware on the bus — no Raspberry Pi or Nerves target needed in the loop.

Status: early release. Tested against an Adafruit FT232H breakout on Linux. See CHANGELOG.md for what's in each version.

Quick start

# mix.exs
def deps do
  [
    {:circuits_ft232h, "~> 0.1"}
  ]
end
# config/config.exs
import Config

# Pick the backends you actually want. Pulling in the dep is harmless if you
# only enable one or two.
config :circuits_i2c, default_backend: CircuitsFT232H.I2C.Backend
config :circuits_spi, default_backend: CircuitsFT232H.SPI.Backend
config :circuits_gpio, default_backend: CircuitsFT232H.GPIO.Backend

Then use the Circuits libraries as usual:

# Enumerate
Circuits.I2C.bus_names()
#=> ["ftdi-3:8-i2c"]

# Open + scan
{:ok, i2c} = Circuits.I2C.open("ftdi-3:8-i2c")
Circuits.I2C.detect_devices(i2c)
#=> [0x29]

Bus / controller names are "ftdi-<id>" where <id> is the chip's USB bus and address (e.g. "3:8"). Once FTDI serial numbers are read at init, this will become the serial string instead.

How modes share a chip

A single FT232H has one MPSSE engine. We can use it as either an I2C master or an SPI master at any one moment — whichever bus is opened first locks the chip into that mode until it's closed. GPIO can run alongside whichever protocol is active, on any pin the protocol doesn't reserve:

Active mode Reserved pins Free for GPIO
none AD0-AD7, AC0-AC7
I2C AD0-AD2AD3-AD7, AC0-AC7
SPI AD0-AD3AD4-AD7, AC0-AC7

Opening a GPIO pin reserved by the active protocol fails with {:error, {:pin_reserved_by_protocol, mode, pin}}. Claiming an I2C/SPI mode while a conflicting GPIO is open fails with {:error, {:pin_busy, pin}}.

Wiring

The FT232H breakout exposes the MPSSE port on the D0-D7 pins (matching the silkscreen labels AD0-AD7 in code) and the C0-C7 pins (matching the labels AC0-AC7).

SPI

Breakout pin Code label SPI signal
D0AD0 SCK
D1AD1 MOSI
D2AD2 MISO
D3AD3 CS (active low)
GND GND
{:ok, spi} = Circuits.SPI.open("ftdi-3:8-spi", mode: 0, speed_hz: 1_000_000)
{:ok, response} = Circuits.SPI.transfer(spi, <<0xAA, 0x55>>)
Circuits.SPI.close(spi)

Supported SPI options:

I2C

Breakout pin Code label I2C signal
D0AD0 SCL
D1 and D2AD1 + AD2 SDA (tied together)
GND GND

I2C requires:

{:ok, i2c} = Circuits.I2C.open("ftdi-3:8-i2c", speed_hz: 100_000)
{:ok, chip_id} = Circuits.I2C.write_read(i2c, 0x29, <<0x00>>, 1)
Circuits.I2C.close(i2c)

Supported I2C options:

I2C transactions run at the requested bus rate via MPSSE 3-phase clocking (ENABLE_DRIVE_ZERO + ENABLE_3_PHASE_CLOCKING per FTDI AN_108). On every bus open, a 16-pulse bus-recovery sequence runs to free any slave stuck holding SDA low from a previous crashed program.

Clock stretching

I2C slaves are allowed to hold SCL low to make the master wait while they finish internal work (page writes, A/D conversions, etc.). MPSSE doesn't detect this natively — its clock generator just keeps running. We can fix this by reusing MPSSE's JTAG "adaptive clocking" feature: with ADBUS0 (SCL) externally jumpered to ADBUS7 (the RTCK pin), MPSSE can be told to pause its clock until ADBUS7 actually reads high.

Enable per bus:

{:ok, i2c} = Circuits.I2C.open("ftdi-3:9-i2c", clock_stretching: true)

When :clock_stretching is true:

Wiring requirement: a wire jumpering D0 (SCL) directly to D7 (the silkscreen label corresponding to ADBUS7).

Note: enabling clock stretching subtly changes the SCL waveform timing. A few well-behaved slaves with picky tolerances may NACK when adaptive clocking is on. If you only enable this for slaves that actually need it, you'll be fine.

GPIO

# By label (matches the breakout silkscreen)
{:ok, led} = Circuits.GPIO.open("AD7", :output, initial_value: 0)
Circuits.GPIO.write(led, 1)
Circuits.GPIO.read(led)
Circuits.GPIO.close(led)

# By integer (0..7 = AD0..AD7, 8..15 = AC0..AC7)
{:ok, pin} = Circuits.GPIO.open(12, :input)
Circuits.GPIO.read(pin)

# Fully qualified — required when multiple FT232Hs are attached
{:ok, pin} = Circuits.GPIO.open({"ftdi-3:8", "AD4"}, :output)

Pull modes:

GPIO interrupts are emulated

Circuits.GPIO.set_interrupts/3 is supported, but be aware that the FT232H has no hardware-generated pin-change notifications. We emulate interrupts by sampling pin state on a fixed interval — by default every 10 ms — and emitting {:circuits_gpio, gpio_spec, timestamp, value} messages on edges.

Pulses shorter than the poll interval will be missed. Multiple edges within a single interval are collapsed into one notification with the final state. Edge detection is purely host-side polling, not chip hardware.

Configure the poll interval with:

config :circuits_ft232h, gpio_poll_interval_ms: 5

Lower values reduce missable pulse width but use more USB bandwidth and CPU. Practical floor is ~2 ms (USB round-trip latency). For fast signals, use an actual microcontroller — this is a host-side development tool, not a real-time peripheral.

:suppress_glitches is accepted but currently a no-op.

Installation

You need libusb-1.0 on the host:

Linux

The kernel auto-binds the ftdi_sio driver to the FT232H, exposing it as a serial port. CircuitsFT232H detaches that driver automatically each time it opens the device — no permanent unbind is needed.

You'll need permission to talk to the USB device. The simplest fix is the udev rule we ship in udev/99-ft232h.rules:

sudo cp udev/99-ft232h.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger

The rule combines a plugdev-group ownership with the modern uaccess tag, so members of plugdev and the current local seat user both get access without re-login.

macOS

Works with libusb once installed. Apple's built-in FTDI VCP driver auto-binds similarly to Linux's ftdi_sio; the same detach-on-open approach handles it.

Windows

Not yet supported.

Limitations

Documentation

Generated docs are at https://hexdocs.pm/circuits_ft232h. Or build them yourself with mix docs.

Acknowledgements

The MPSSE protocol details, especially the I2C bit-banging tricks (DRIVE_ZERO, 3-phase clocking, the AD1/AD2 SDA tied-pin pattern), and the FTDI 1-bit-read LSB-positioning quirk, were all reverse-engineered from pyftdi and FTDI Application Note AN_108.

GitHub mirror

This repository is mirrored on GitHub from its primary location on my Forgejo instance. Feel free to raise issues and open PRs on GitHub.

License

This software is licensed under the terms of the Apache 2.0 license. See the LICENSE file in this repository for the full terms.