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 a MagicAlias, and defines the transports chip drivers are written against. Adapter packages plug the actual hardware in. A gpio resource on the Venusian IOPool dock lets drivers watch pins, receive serial bytes and run bus work on the app's tick instead of blocking.
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, the gpio dock ← this package
→ dept-of-scrapyard-robotics/* chip drivers
- PHP 8.4 or newer
- A Venusian application on
venusian/framework0.8 - 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, over MPSSE; needs ext-ftdi |
Without an adapter, every protocol uses the built-in none driver, which throws as soon as you try to connect.
composer require scrapyard-io/framework Both service providers are discovered automatically. The framework registers the protocol managers and the MagicAliases I2C, SPI, UART, DigitalIO, PWM and GPIO. 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' => [
'i2c' => ['default' => 'native'],
'spi' => ['default' => 'native'],
'uart' => ['default' => 'native'],
'digital-in' => ['default' => 'native'], // the DigitalIO default
'digital-out' => ['default' => 'native'],
'pwm' => ['default' => 'native'],
],
'io_pools' => [
'enabled' => true, // register the gpio dock resource
'defer_per_tick' => null, // cap deferred jobs per tick; null runs them all
],
];I2C::driver() with no name uses the default. Naming the driver, as the examples below do, works whatever the defaults are.
Read a register from a device at 0x21 on a Raspberry Pi's i2c-1:
use GeneralPurposeIO\I2C\I2C;
$device = 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 byteEvery 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.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.
use GeneralPurposeIO\I2C\I2C;
$bus = I2C::driver('native')->connectTo(1)->register();
$display = $bus->device(1, 0x3C);
$fan = $bus->device(1, 0x21);
$ft232h = I2C::driver('usb')->connectTo('ft232h')->register()->device('ft232h', 0x53);Transports on the same bus share one connection. Each call addresses its own device.
use GeneralPurposeIO\SPI\SPI;
$panel = SPI::driver('native')
->connectTo(0) // /dev/spidev0.*
->mode(0) // SPIMode case or 0–3
->speed(8_000_000) // Hz
->register()
->device(0, 0); // chip select 0endianness() and chipSelect() are also available. On the usb driver, set the bus speed with clockRate(), which takes an MPSSEClockRate, because that factory ignores speed():
use Microscrap\Bindings\MPSSE\Enums\MPSSEClockRate;
$panel = SPI::driver('usb')->connectTo('ft232h')->mode(0)->clockRate(MPSSEClockRate::TEN_MHZ)->register()->device('ft232h', 0);use GeneralPurposeIO\Contracts\UART\Parity;
use GeneralPurposeIO\UART\UART;
$gps = UART::driver('native')
->connectTo('/dev/ttyAMA0')
->baud(9600)
->parity(Parity::NONE)
->register()
->device('/dev/ttyAMA0');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.
use GeneralPurposeIO\Contracts\Digital\LineBias;
use GeneralPurposeIO\Digital\DigitalIO;
$pins = DigitalIO::driver('native')->connectTo(0)->register(); // gpiochip0
$led = $pins->output(0, 17);
$button = $pins->input(0, 27, LineBias::PULL_UP);
$led->high();
$pressed = ! $button->read();input() also takes active_low, which inverts the level the pin reports.
On an FTDI board, the I2C or SPI connection already registers the device, so its GPIOL pins are available straight away:
$dc = DigitalIO::driver('usb')->output('ft232h', 1);use GeneralPurposeIO\PWM\PWM;
$servo = 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.
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) |
SPI\SPITransport |
read($len), write($data), transfer($data) |
UART\UARTTransport |
read($len), write($data), flush(), path(), pollBytes($max) |
Digital\DigitalOutTransport |
low(), high(), read(), write($state) |
Digital\DigitalInTransport |
read(), pollEdges($rising, $falling), listen($timeout_ms, $rising, $falling) |
PWM\PWMTransport |
get/set for Period, DutyCycle, Enable and Polarity |
Writes take a byte array or a binary string, and reads return a byte array, or false when the bus refuses. listen() blocks for up to $timeout_ms and returns one DigitalEdgeEvent or null. pollEdges() never waits.
A bus transport is a view on a connection its driver owns. Closing a chip driver shouldn't close the bus other devices share.
Plain transport calls block. When the Venusian IOPool dock is in the app and gpio.io_pools.enabled is true, the framework also registers a gpio resource on it. Each dock tick polls, runs and sends what drivers handed it, without waiting. Reach it through GPIO:: or IOPool::gpio().
| Call | What happens on each tick | Mail pushed |
|---|---|---|
watch($pin, rising: true, falling: false) |
polls an input pin for edges | DigitalEdgeOccurrence per edge |
receive($port, max_bytes: 4096) |
polls a UART port for buffered bytes | UARTBytesOccurrence |
defer($name, $work, $envelope = null) |
runs $work once, on the next tick |
TransferCompletion; the returned Presumption settles with it |
every($name, $work, ticks: 1) |
runs $work every $ticks ticks until the Recurrence is stopped |
TransferCompletion per run |
stream($name, $write, $bytes, $chunk) |
hands $write one $chunk of $bytes per tick |
TransferCompletion with the bytes sent; progress on the Presumption |
unwatch(), stopReceiving(), inFlight($name), recurring($name) and streaming($name) undo or look up each one. A name can have only one job in flight at a time.
use GeneralPurposeIO\Contracts\Core\Mail\TransferCompletion;
use Voyager\IOPools\MagicAliases\IOPool;
$gpio = IOPool::gpio();
$gpio->watch($button, rising: false, falling: true);
$gpio->defer('fan.temp', fn (): array => $fan->writeRead([0xFC], 1))
->onSuccess(fn (TransferCompletion $done) => printf("%d °C\n", $done->result[0]))
->onFail(fn (TransferCompletion $done) => error_log($done->error->getMessage()));
$poll = $gpio->every('fan.poll', fn () => $fan->writeRead([0xFC], 1), ticks: 30);
while (true) {
IOPool::pump(); // one tick of every dock resource
foreach (IOPool::drain() as $mail) {
// DigitalEdgeOccurrence, TransferCompletion, …
}
usleep(10_000);
}
$poll->stop();In a Surface LiveApplication, $app->tick() pumps the dock and $app->events() drains it.
Two rules keep the tick predictable:
- Work that blocks spends the tick. The dock never waits, but a closure that does holds up everything after it.
- Failures become mail. A pin, port or recurrence that throws produces a
SourceFaultOccurrenceand stays registered. A deferred job or stream that throws settles itsTransferCompletionwith the error, andok()returnsfalse.
Work registered during a tick runs on the next one.
dept-of-scrapyard-robotics/* packages build on these pieces:
| Class | Use |
|---|---|
IntegratedCircuits\Bootable |
base chip with boot() / hasBooted(); boots in the constructor when asked |
IntegratedCircuits\DataRegister |
readonly register breakout: toBits(), toByte(), fromByte(), none() |
Contracts\IntegratedCircuits\Sensor, Actuator, DisplayPanel |
what kind of chip it is |
Contracts\IntegratedCircuits\ReadWriter |
read($register, $length) / write($register, $data) for a chip transport |
Contracts\NutsAndBolts\Splices16Bits |
split 16-bit registers into bytes, and decode signed little-endian values |
Contracts\IntegratedCircuits\CircuitException |
base for a chip's exceptions |
Contracts\IntegratedCircuits\Attributes\IntegratedCircuit |
how the chip can be wired — #[IntegratedCircuit('I2C', ['SPI', 'DigitalIO'])] |
Contracts\IntegratedCircuits\Attributes\Pinout |
what each channel needs wired, index-aligned with those options |
The global helpers array2bytes(), bytes2array(), byte2bits() and bits2byte() convert between byte arrays, binary strings and bits.
A chip package catalogs what it ships from its provider's boot(); nothing is built until something asks.
Circuit::addCircuit('st7789', ST7789::class);The wiring lives in the app, in the config the chip package publishes:
php workshop vendor:publish --tag=st77xx-config # config/circuits/st7789.php'default_config' => 'spi',
'configs' => ['spi' => [
'driver' => 'usb', 'device' => 'ft232h', 'chip_select' => 0,
'speed' => 10_000_000, 'width' => 320, 'height' => 240,
'mad_ctrl' => ['pixel_direction_vertical' => true],
'dc' => ['driver' => 'usb', 'device' => 'ft232h', 'pin' => 1],
'rst' => ['driver' => 'usb', 'device' => 'ft232h', 'pin' => 2],
]],Then one call hands back a wired, booted chip — no adapter, bus or pin at the call site:
$panel = Circuit::conjure('st7789'); // default_config
$left = Circuit::conjure('st7789', 'left'); // a named configThe config entry is the signature of the chip's own spi() factory: its 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. Surface reaches the same door with EmbeddedDisplay::panel('st7789').
Every exception descends from GeneralPurposeIO\Contracts\Core\GPIOLevelException. Each protocol has its own, such as I2CException or SPIException. 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.
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, protocol enums, exceptions, the dock resource contract and its mail |
gpio/digital |
DigitalIO and its connection classes |
gpio/i2c |
I2C and its connection classes |
gpio/spi |
SPI and its connection classes |
gpio/uart |
UART and its connection classes |
gpio/pwm |
PWM and its connection classes |
gpio/integrated-circuits |
Bootable, DataRegister, the circuit catalog and circuit:make-profile |
gpio/nuts-and-bolts |
the byte helpers |
The aggregate service provider, the GPIO and Circuit aliases and the dock resource are part of scrapyard-io/framework only.
composer install
vendor/bin/pestThe suite uses fake drivers and transports, so it runs without hardware or the PHP extensions.
MIT. See LICENSE.