scrapyard-io / framework
The ScrapyardIO GPIO Framework.
Requires
- php: ^8.4|^8.5|^8.6
- venusian-voyager/collections: ^0.9.0
- venusian-voyager/contracts: ^0.9.0
- venusian-voyager/io-pools: ^0.9.0
- venusian-voyager/nuts-and-bolts: ^0.9.0
Requires (Dev)
- microscrap/ftdi: ^0.9.0
- microscrap/gpio: ^0.9.0
- microscrap/i2c: ^0.9.0
- microscrap/mpsse: ^0.9.0
- microscrap/posix: ^0.9.0
- microscrap/spi: ^0.9.0
- microscrap/uart: ^0.9.0
- pestphp/pest: ^4
- venusian-voyager/config: ^0.9.0
- venusian-voyager/vessel: ^0.9.0
Suggests
- microscrap/ftdi: ^0.9.0 - FTDI USB drivers
- microscrap/gpio: ^0.9.0 - POSIX digital IO (libgpiod v2 over ext-posi)
- microscrap/i2c: ^0.9.0 - POSIX I2C
- microscrap/mpsse: ^0.9.0 - MPSSE USB drivers
- microscrap/spi: ^0.9.0 - POSIX SPI
- microscrap/uart: ^0.9.0 - POSIX UART
Provides
None
Conflicts
None
Replaces
- gpio/contracts: 0.9.0
- gpio/digital: 0.9.0
- gpio/i2c: 0.9.0
- gpio/integrated-circuits: 0.9.0
- gpio/nuts-and-bolts: 0.9.0
- gpio/pwm: 0.9.0
- gpio/spi: 0.9.0
- gpio/uart: 0.9.0
This package is auto-updated.
Last update: 2026-09-26 02:25:56 UTC
README
Talk to hardware from a Venusian app: I2C, SPI, UART, digital pins and PWM behind one API, whether the bus is a Raspberry Pi's own or an FTDI USB board.
scrapyard-io/framework gives each protocol a connection manager and defines the transports chip drivers are written against. Adapter packages plug the actual hardware in. Every call blocks by default. When the app has an IOPools event loop, waits share the loop, pins and ports can mail what they receive, and bus work can be offloaded to a work target.
ext-posi / ext-ftdi 1:1 system and libftdi calls
→ microscrap/* libgpiod, i2c-dev, spidev, termios, libmpsse in PHP
→ microscrap/scrapyard-* adapters: the `native` and `usb` drivers
→ scrapyard-io/framework protocol managers, transports, circuits ← this package
→ dept-of-scrapyard-robotics/* chip drivers
Requirements
- PHP 8.4 or newer
- A Venusian 0.9 application (
venusian-voyager/*0.9) - At least one adapter:
| Adapter | Driver name | Protocols | Hardware |
|---|---|---|---|
microscrap/scrapyard-linux |
native |
I2C, SPI, UART, digital, PWM | Linux i2c-dev, spidev, serial ports, libgpiod, sysfs PWM; needs ext-posi |
microscrap/scrapyard-usb |
usb |
I2C, SPI, UART, digital | FTDI boards such as the FT232H: MPSSE for I2C, SPI and pins, the UART engine for serial; needs ext-ftdi |
Without an adapter, every protocol uses the built-in none driver, which throws as soon as you try to connect.
Installation
composer require scrapyard-io/framework
The service provider is discovered automatically. It binds one connection manager per protocol and the circuit catalog:
| Container key | Class |
|---|---|
gpio.i2c |
GeneralPurposeIO\I2C\I2CConnectionManager |
gpio.spi |
GeneralPurposeIO\SPI\SPIConnectionManager |
gpio.uart |
GeneralPurposeIO\UART\UARTConnectionManager |
gpio.digital |
GeneralPurposeIO\Digital\DigitalOConnectionManager |
gpio.pwm |
GeneralPurposeIO\PWM\PWMConnectionManager |
circuit |
GeneralPurposeIO\IntegratedCircuits\CircuitRegistry |
Each adapter registers its driver name on every manager it supports.
To choose default drivers, publish the config:
php computer vendor:publish --tag=gpio-config
// config/gpio.php return [ 'protocols' => [ 'uart' => ['default' => 'native'], 'i2c' => ['default' => 'native'], 'spi' => ['default' => 'native'], 'digital-in' => ['default' => 'native'], // the gpio.digital default 'digital-out' => ['default' => 'native'], 'pwm' => ['default' => 'native'], ], ];
driver() with no name uses the default. Naming the driver, as the examples below do, works whatever the defaults are.
Quick start
Read a register from a device at 0x21 on a Raspberry Pi's i2c-1:
$device = app('gpio.i2c')->driver('native') ->connectTo(1) // open /dev/i2c-1 ->register() // keep the connection on the driver ->device(1, 0x21); // a transport for one address on it $device->probe(); // true when something answers $bytes = $device->writeRead([0xFC], 1); // select register 0xFC, read one byte
Connecting
Every protocol follows the same four steps:
driver($name)picks an adapter.connectTo($device)starts a connection. For SPI and UART, set the bus options here.register()opens the connection and stores it on the driver. The driver hands out transports from it until the app ends or you calldisconnect($device).device(...)returns a transport for one address, chip select, port, pin or channel. It returnsnullif that device was never registered.
Connecting the same device twice throws. Register a bus once, then call device() as often as you need.
I2C
$bus = app('gpio.i2c')->driver('native')->connectTo(1)->register(); $display = $bus->device(1, 0x3C); $fan = $bus->device(1, 0x21); $ft232h = app('gpio.i2c')->driver('usb')->connectTo('ft232h')->register()->device('ft232h', 0x53);
Transports on the same bus share one connection. Each call addresses its own device.
SPI
use GeneralPurposeIO\Contracts\SPI\SPIMode; $panel = app('gpio.spi')->driver('native') ->connectTo(0) // /dev/spidev0.* ->mode(SPIMode::MODE_0) // or 0–3 ->speed(8_000_000) // Hz ->register() ->device(0, 0); // chip select 0
endianness() and chipSelect() are also available. The usb driver takes speed() in Hz too, or an MPSSEClockRate through clockRate().
A slave can run at its own clock with $panel->speed($hz), and $panel->select(fn () => ...) holds its chip select across every call the closure makes.
UART
use GeneralPurposeIO\Contracts\UART\Parity; $gps = app('gpio.uart')->driver('native') ->connectTo('/dev/ttyAMA0') ->baud(9600) ->parity(Parity::NONE) ->register() ->device('/dev/ttyAMA0'); $gps->write("\$PMTK605*31\r\n"); $line = $gps->readUntil("\r\n", timeout_ms: 1000); // null on timeout
stopBits(), dataBits() and flowControl() take their enums or the matching integers. The defaults are 9600 baud, 8 data bits, no parity, 1 stop bit and no flow control.
On the usb driver, name the FTDI product instead of a path: connectTo('ft232h'). One FTDI interface runs either its UART engine or MPSSE, so a port there refuses I2C, SPI and pins on the same interface until it is disconnected.
Each port keeps one unread buffer of 64 KB. When it overflows, the oldest bytes go and dropped() counts them. dtr() and rts() drive the modem lines.
Digital pins
use GeneralPurposeIO\Contracts\Digital\LineBias; $pins = app('gpio.digital')->driver('native')->connectTo(0)->register(); // gpiochip0 $led = $pins->output(0, 17); $button = $pins->input(0, 27, LineBias::PULL_UP); $led->high(); $pressed = ! $button->read(); $edge = $button->listen(500, rising_events: false, falling_events: true); // DigitalEdgeEvent or null
input() also takes active_low, which inverts the level the pin reports. pollEdges() returns the unread edges without waiting.
On an FTDI board, an I2C or SPI connection already registers the device, so its GPIOL pins are available straight away:
$dc = app('gpio.digital')->driver('usb')->output('ft232h', 1);
PWM
$servo = app('gpio.pwm')->driver('native')->connectTo(0)->register()->device(0, 0); // pwmchip0, channel 0 $servo->setPeriod(20_000_000); // nanoseconds $servo->setDutyCycle(1_500_000); $servo->setEnable(true);
Only the native driver provides PWM.
Transports
Chip drivers depend on these contracts from GeneralPurposeIO\Contracts, not on any adapter. Every transport also has close().
| Contract | Methods |
|---|---|
I2C\I2CTransport |
probe(), read($len), write($data), writeRead($data, $len), bulkWrite($messages), via($target) |
SPI\SPITransport |
read($len), write($data), transfer($data), writeRead($data, $len), select($body), speed($hz), chipSelect(), via($target) |
UART\UARTTransport |
read($len, $timeout_ms), readUntil($delimiter, $timeout_ms), write($data, $timeout_ms), pollBytes($max), flush(), dropped(), dtr($on), rts($on), path(), watch(), unwatch() |
Digital\DigitalOutTransport |
low(), high(), read(), write($state) |
Digital\DigitalInTransport |
read(), pollEdges($rising, $falling), listen($timeout_ms, $rising, $falling), watch($rising, $falling), unwatch() |
PWM\PWMTransport |
get/set for Period, DutyCycle, Enable and Polarity, channel(), via($target) |
Writes take a byte array or a binary string, and reads return a byte array, or false when the bus refuses. Timeouts are in milliseconds: -1 waits without limit and 0 never waits. A UART read() returns [] on timeout, and write() throws once its timeout passes with bytes unsent; the exception carries how many went out.
A bus transport is a view on a connection its driver owns. Closing a chip driver shouldn't close the bus other devices share.
The event loop
Without an IOPools event loop bound in the container, every wait blocks the process in the adapter. Once Voyager\Contracts\IOPools\Loop is bound, the same calls share the loop instead:
- Waits stay out of the way.
listen(), UARTread(),readUntil()andwrite()suspend their fiber inside$loop->async(). On the main stack they turn the loop until they finish, so timers and other resources keep running. - Pins and ports can mail.
watch()puts an input pin or a UART port on the loop, andunwatch()takes it off. Each edge or chunk is mailed and also stays readable throughlisten()orread().watch()throws without a loop. - Bus work can be offloaded.
via()returns the same calls as promises.
| Watched | Resource name | |
|---|---|---|
| input pin | gpio.edge.<device>.<pin> |
DigitalEdgeEvent: device, pin, edge, timestamp, seqno |
| UART port | gpio.uart.<device> |
UARTReceived: device, bytes, timestamp, seqno |
Mail is delivered on the turns of $loop->run(), through the loop's mail handler. The default handler dispatches each item as a signal under its resource name, so a listener can take one port, a wildcard, or the class. A wildcard listener receives the name and the mail in an array:
use GeneralPurposeIO\Contracts\UART\UARTReceived; use Voyager\Contracts\IOPools\Loop; app('signals')->listen('gpio.uart./dev/ttyAMA0', fn (UARTReceived $chunk) => print $chunk->bytes); app('signals')->listen('gpio.edge.*', fn (string $name, array $mail) => printf("%s %s\n", $name, $mail[0]->edge->value)); app('signals')->listen(UARTReceived::class, fn (UARTReceived $chunk) => error_log(strlen($chunk->bytes).' bytes')); $gps->watch(); $button->watch(rising_events: false, falling_events: true); app(Loop::class)->run();
Both mail classes round-trip through toData() and fromData(), so they cross a worker or queue wire intact.
Offloading with via()
via($target) hands a transport's calls to an IOPools work target and returns a Voyager\Contracts\IOPools\Promise. $target names one of the app's work targets (sync, defer, pool, concurrency, queue). null uses the adapter's own async path where it has one, and otherwise the app's default target.
$fan->via('pool')->writeRead([0xFC], 1) ->then(fn (array $bytes) => printf("%d °C\n", $bytes[0])) ->error(fn (Throwable $e) => error_log($e->getMessage()));
- Jobs for one slave run one at a time, in call order; different slaves run side by side.
- A blocking call on a slave first waits for the jobs queued on it before the call.
close()lets the running job finish and rejects the queued ones.- A worker's exception comes back as the scrapyard exception it was.
A chip driver ships its own multi-step work as a Contracts\NutsAndBolts\BusJob and runs it with via()->run($job). run(GPIOTransport $bus) receives the transport on whichever side of the wire the job lands.
via() needs a bound loop and IOPools' work targets; without them it throws an exception that says which is missing.
Writing chip drivers
dept-of-scrapyard-robotics/* packages build on these pieces:
| Class | Use |
|---|---|
IntegratedCircuits\IntegratedCircuit |
base class for every chip |
IntegratedCircuits\Bootable |
a chip with boot() / hasBooted(): implement _boot(); boots in the constructor when $boot_now is true |
IntegratedCircuits\DataRegister |
readonly register breakout: toBits(), toByte(), fromByte(), none() |
Contracts\IntegratedCircuits\Sensor, Actuator, DisplayPanel |
what kind of chip it is |
Contracts\IntegratedCircuits\Switchable, WindowAddressable, RefreshesOnCommand |
what a display panel can do |
Contracts\IntegratedCircuits\ReadWriter |
read($register, $length) / write($register, $data) for a chip transport |
Contracts\IntegratedCircuits\DataCommander |
data($bytes) / command($register, $data) for a chip with a data/command line |
Contracts\NutsAndBolts\Splices16Bits |
split 16-bit registers into bytes, and decode signed little-endian values |
Contracts\NutsAndBolts\BusJob |
multi-step bus work that via()->run() can offload |
Contracts\IntegratedCircuits\CircuitException |
base for a chip's exceptions |
The global helpers array2bytes(), bytes2array(), byte2bits() and bits2byte() convert between byte arrays, binary strings and bits.
Circuits
A chip package catalogs what it ships from its provider's boot(); nothing is built until something asks.
app('circuit')->addCircuit('st7789', ST7789::class);
The wiring lives in the app, in the config the chip package publishes (config/circuits/st7789.php):
'default_config' => 'spi', 'configs' => ['spi' => [ 'driver' => 'usb', 'device' => 'ft232h', 'chip_select' => 0, 'speed' => 10_000_000, 'width' => 320, 'height' => 240, 'dc' => ['driver' => 'usb', 'device' => 'ft232h', 'pin' => 1], 'rst' => ['driver' => 'usb', 'device' => 'ft232h', 'pin' => 2], ]],
Then one call hands back a wired chip, with no adapter, bus or pin at the call site:
$panel = app('circuit')->conjure('st7789'); // default_config $left = app('circuit')->conjure('st7789', 'left'); // a named config
A config is named after its protocol unless it sets 'protocol', and the chip's public static factory of that name builds it: the config's keys are passed as named arguments. A key the factory does not take is dropped; a required one the config lacks is an error that names it. build($slug, $protocol, $params) does the same from an array.
Errors
Every exception descends from GeneralPurposeIO\Contracts\Core\GPIOLevelException. Each protocol has its own, such as I2CException or UARTException. The none driver throws one that names the config key to set:
No I2C connection driver is configured. Set gpio.protocols.i2c.default to an installed adapter.
Split packages
The framework is also published as components, for drivers that should depend on less than the whole framework. scrapyard-io/framework replaces them all.
| Package | Contents |
|---|---|
gpio/contracts |
transports, offloaded transports, protocol enums, exceptions, BusJob and the mail classes |
gpio/digital |
the gpio.digital manager and its connection classes |
gpio/i2c |
the gpio.i2c manager and its connection classes |
gpio/spi |
the gpio.spi manager and its connection classes |
gpio/uart |
the gpio.uart manager and its connection classes |
gpio/pwm |
the gpio.pwm manager and its connection classes |
gpio/integrated-circuits |
IntegratedCircuit, Bootable, DataRegister and the circuit catalog |
gpio/nuts-and-bolts |
the byte helpers |
The aggregate service provider and config/gpio.php are part of scrapyard-io/framework only.
Testing
composer install vendor/bin/pest
The suite uses fake drivers and transports, so it runs without hardware or the PHP extensions.
License
MIT. See LICENSE.