CircuitsFT232H
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.mdfor 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.BackendThen 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-AD2 | AD3-AD7, AC0-AC7 |
| SPI | AD0-AD3 | AD4-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 |
|---|---|---|
D0 | AD0 | SCK |
D1 | AD1 | MOSI |
D2 | AD2 | MISO |
D3 | AD3 | 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:
:mode(0..3, default 0). Modes 1 and 3 (CPHA=1) enable MPSSE 3-phase clocking; the effective SCK is ~2/3 of the requested rate in those modes.:speed_hz(default 1 MHz). FT232H supports up to 30 MHz.:lsb_first(default false).:bits_per_wordaccepted only when 8.:delay_usaccepted but ignored — MPSSE has no native inter-byte delay.
I2C
| Breakout pin | Code label | I2C signal |
|---|---|---|
D0 | AD0 | SCL |
D1 and D2 | AD1 + AD2 | SDA (tied together) |
GND | — | GND |
I2C requires:
D1andD2jumpered together externally — the FT232H drives SDA onAD1and samples it onAD2. Without the jumper, reads always come back as0xFF.- External pull-ups on SCL and SDA. Use 4.7 kΩ for 100 kHz buses, 2.2 kΩ for 400 kHz, 1 kΩ for 1 MHz. The FT232H's internal pull-ups are far too weak.
{: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:
:speed_hz(default 100 kHz, max 1 MHz).:clock_stretching(defaultfalse) — see below.
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:
ADBUS7is reserved for the lifetime of the bus and rejected for GPIO opens.-
Every I2C transaction is wrapped in
ENABLE_CLK_ADAPTIVE/DISABLE_CLK_ADAPTIVEopcodes, leaving the chip free between transactions.
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:
:not_setand:pullupaccepted as no-ops. The FT232H always has weak internal pull-ups (~75 kΩ) on inputs.:pulldownand:nonereturn{:error, :not_supported}— neither is controllable at runtime.
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: 5Lower 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:
-
Debian/Ubuntu:
sudo apt install libusb-1.0-0-dev -
macOS:
brew install libusb
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
- No Windows support yet.
- GPIO "interrupts" are emulated via host-side polling (default 10 ms). Pulses shorter than the poll interval will be missed. See the GPIO section above.
- I2C clock stretching is opt-in and requires an external jumper from
D0(SCL) toD7. See the I2C clock-stretching section above. - FTDI serial numbers are read on enumeration and used as the canonical
chip id when programmed. Chips with a blank EEPROM fall back to
\"<bus>:<address>\", which is not stable across replugs. Circuits.I2C.write_read/5uses a repeated-start condition; some I2C peripherals (notably the Bosch BNO055) don't support repeated-start reliably. Use separatewrite/4+read/4calls for those devices.:bits_per_wordon SPI is hardcoded to 8.:delay_usbetween SPI transfers is accepted but ignored.
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.