dept-of-scrapyard-robotics / ssd1680
Drive SSD1680 black/white and black/white/red ePaper panels over SPI with the ScrapyardIO GPIO framework.
Package info
github.com/DeptOfScrapyardRobotics/SSD1680
pkg:composer/dept-of-scrapyard-robotics/ssd1680
Requires
- php: ^8.4|^8.5|^8.6
- gpio/contracts: ^0.10.0
- gpio/integrated-circuits: ^0.10.0
- venusian-surface/contracts: ^0.10.0
- venusian-voyager/nuts-and-bolts: ^0.10.0
- venusian-voyager/vessel: ^0.10.0
Requires (Dev)
- gpio/digital: ^0.10.0
- gpio/spi: ^0.10.0
- pestphp/pest: ^4
- venusian-voyager/config: ^0.10.0
Suggests
- gpio/digital: ^0.10.0 - DC, RST, BUSY and PWR pins for SSD1680SPITransport
- gpio/spi: ^0.10.0 - SPI connections for SSD1680SPITransport
- microscrap/scrapyard-linux: ^0.10.0 - native spidev and libgpiod drivers
- microscrap/scrapyard-usb: ^0.10.0 - MPSSE SPI and pins over FTDI
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-05 19:44:11 UTC
README
Drive SSD1680 ePaper panels from PHP over SPI, black/white and black/white/red, using the ScrapyardIO GPIO framework.
dept-of-scrapyard-robotics/ssd1680 powers and boots the controller, writes your frames into its RAM a window at a time, and refreshes the panel: a full refresh on either kind, and a fast partial refresh on black/white panels. You describe the wiring in a config file, ask the circuit catalog for the panel, and send it bytes packed the way its formatSpec() describes, which is what a Surface ePaper framebuffer produces.
ext-posi / ext-ftdi 1:1 system and libftdi calls
→ microscrap/* libgpiod, spidev, libmpsse in PHP
→ microscrap/scrapyard-* adapters: the `native` and `usb` drivers
→ scrapyard-io/framework protocol managers, transports, the circuit catalog
→ dept-of-scrapyard-robotics/ssd1680 ← this package
| Panel | Class | Catalog slug | Refresh |
|---|---|---|---|
| black/white | SSD1680BW |
ssd1680-bw |
full, partial |
| black/white/red | SSD1680BWR |
ssd1680-bwr |
full (about 18 s) |
Requirements
- PHP 8.4 or newer
- A Venusian 0.10 application with the
scrapyard-io/framework0.10 components (gpio/spi,gpio/digital,gpio/integrated-circuits) venusian-surface/contracts0.10, for theFormatSpecthe panel describes its bytes with- An adapter for your hardware:
microscrap/scrapyard-linux(drivernative) for a Raspberry Pi or other Linux board:spidevandlibgpiod, needsext-posimicroscrap/scrapyard-usb(driverusb) for FTDI MPSSE boards such as the FT232H, needsext-ftdi
venusian-surface/framebuffers0.10 if you want Surface to pack your frames
Installation
composer require dept-of-scrapyard-robotics/ssd1680
The service provider is discovered automatically. It merges the package's wiring configs under circuits.ssd1680-bw and circuits.ssd1680-bwr, and registers both panel kinds with the circuit catalog. To publish the configs into your app, run:
php computer vendor:publish --tag=ssd1680-config
That writes config/circuits/ssd1680-bw.php and config/circuits/ssd1680-bwr.php, each with driver => 'none' until you fill in your bench.
Quick start
The LAFVIN 2.13 inch e-Paper HAT (250 × 122, black/white) on a Raspberry Pi. Its pins, shared by Waveshare's 2.13 inch HATs including the black/white/red (B) V4: DC on GPIO25, RST on GPIO17, BUSY on GPIO24, chip select on CE0, and a power switch on GPIO18 that must be driven high.
// config/circuits/ssd1680-bw.php return [ 'default_config' => 'spi', 'configs' => [ 'spi' => [ 'driver' => 'native', 'device' => 0, 'chip_select' => 0, 'dc' => ['driver' => 'native', 'device' => 0, 'pin' => 25], 'rst' => ['driver' => 'native', 'device' => 0, 'pin' => 17], 'busy' => ['driver' => 'native', 'device' => 0, 'pin' => 24], 'pwr' => ['enabled' => true, 'driver' => 'native', 'device' => 0, 'pin' => 18], ], ], ];
use Surface\Framebuffers\Native\NativeFramebufferDriver; $panel = app('circuit')->conjure('ssd1680-bw'); // powered, reset and booted $spec = $panel->formatSpec(); $fb = (new NativeFramebufferDriver)->epaper($spec, $panel->width(), $panel->height()); // starts white $fb->writeRgba8($rgba, $panel->width(), $panel->height()); // any RGBA8 image $panel->transmit(0, 0, $fb->flush($spec, true)); $panel->refresh();
The panel is 122 pixels along its sources and 250 along its gates, so it's driven as 122 wide and 250 tall; rotate your image for landscape. Booting takes about 60 ms, and a full refresh about 2.3 seconds.
Connecting
conjure('ssd1680-bw') reads circuits.ssd1680-bw, picks default_config, and calls SSD1680BW::spi() with that entry's keys. You can call the factory directly too:
use DeptOfScrapyardRobotics\Displays\SSD1680\SSD1680BW; $panel = SSD1680BW::spi( 'native', 0, dc: ['driver' => 'native', 'device' => 0, 'pin' => 25], rst: ['driver' => 'native', 'device' => 0, 'pin' => 17], busy: ['driver' => 'native', 'device' => 0, 'pin' => 24], pwr: ['enabled' => true, 'driver' => 'native', 'device' => 0, 'pin' => 18], );
The factory opens an unconnected bus in mode (0 by default) and refuses a bus your app already opened in a different mode. It clocks this chip select at speed, 10 MHz by default and at most 20 MHz, the controller's limit. DC, RST, BUSY and PWR are opened after the bus, so on an FT232H they ride the same USB context as its SPI engine. Leave pwr disabled when the panel has no power switch. Pass width and height for a panel other than 122 × 250 (up to 176 × 296), and boot_now: false to build without booting.
Building the transport yourself
use DeptOfScrapyardRobotics\Displays\SSD1680\Transports\SSD1680SPITransport; $spi = app('gpio.spi')->driver('native')->connectTo(0)->mode(0)->speed(10_000_000)->register()->device(0, 0); $pins = app('gpio.digital')->driver('native')->connectTo(0)->register(); $panel = new SSD1680BW( new SSD1680SPITransport($spi, $pins->output(0, 25), $pins->output(0, 17), $pins->input(0, 24), $pins->output(0, 18)), boot_now: true, );
Drawing
Each ink is a one-bit plane, eight pixels to a byte with the leftmost in bit 7, rows padded to a whole byte. On the black/white panel 1 is white and 0 is black. The black/white/red panel takes two planes back to back: black (1 white, 0 black) then red (1 red).
transmit($x, $y, $bytes, $width, $height) writes a window into the controller's RAM. The panel doesn't change until you call refresh(). RAM columns are bytes, so $x is a multiple of 8, and the width is a multiple of 8 unless the window ends at the panel's right edge:
use Surface\Contracts\Framebuffers\Region; $fb->writeRgba8($square_rgba, 24, 24, 48, 100); $region = new Region(48, 100, 24, 24); $panel->transmit($region->x, $region->y, $fb->flushRegion($region, $spec, true), $region->width, $region->height);
Refreshing
use GeneralPurposeIO\Contracts\IntegratedCircuits\RefreshMode; $panel->refresh(); // full: about 2.3 s, the whole panel flashes $panel->refresh(RefreshMode::PARTIAL); // black/white only: about 0.6 s, only changed pixels move
A partial refresh moves only the pixels that differ from the frame the panel is showing. The controller keeps that frame in its red RAM, so after every refresh the black/white panel copies the windows you sent into it. Run a full refresh after boot before the first partial one, and send the whole panel for that first full refresh, so the red RAM starts out matching the screen; a partial refresh before any full one throws. A full refresh that follows partial ones re-runs the controller's reset and register setup first (RAM is kept), as the panel vendor does; without it the panel ignores the full refresh.
ePaper panels last longer if you don't refresh partially forever; run a full refresh now and then.
The black/white/red panel has no partial waveform: refresh(RefreshMode::PARTIAL) throws there.
Every refresh blocks until the panel lowers BUSY. If BUSY stays high past busy_timeout_ms (30 s by default; a black/white/red full refresh takes about 15 s), it throws.
Sleep
$panel->sleep(); // deep sleep; the image stays on the panel $panel->boot(); // wakes it with a hardware reset and sets it up again
Configuration object
SSD1680Configuration holds the geometry and the values boot writes. Every argument is optional.
| Argument | Default | Meaning |
|---|---|---|
width, height |
122, 250 |
panel size along sources and gates, up to 176 × 296 |
gate_setting |
0 |
gate scan setting: bit 2 bottom to top, bit 1 interlaced, bit 0 first gate |
border_waveform |
new SSD1680BorderWaveform |
border during full refreshes (0x05) |
display_update_control |
new SSD1680DisplayUpdateControl1 |
RAM content option and source output (0x00 0x80) |
temperature_sensor |
SSD1680TemperatureSensor::INTERNAL |
where the waveform's temperature comes from |
max_packet_size |
4096 |
largest data write, in bytes |
busy_timeout_ms |
30000 |
longest a command or refresh may hold BUSY |
Every register breakout checks its fields and throws on a value out of range.
Settings
use DeptOfScrapyardRobotics\Displays\SSD1680\Enums\SSD1680TemperatureSensor; $panel->temperature_sensor = SSD1680TemperatureSensor::EXTERNAL; $panel->border_waveform = new SSD1680BorderWaveform(vbd_option: 0b01, fixed_level: 0b01, gs_transition_control: false, gs_transition_setting: 0);
Any configuration key reads as a property. border_waveform, display_update_control and temperature_sensor can be written, and the panel gets them straight away. The same setters are methods, as are setRAMWindow(), setRAMCursor(), runUpdate(), softwareReset() and waitUntilIdle().
Errors
Everything throws SSD1680Exception, which descends from GeneralPurposeIO\Contracts\IntegratedCircuits\CircuitException.
| Factory | When |
|---|---|
busyTimeout |
BUSY stays high past busy_timeout_ms |
spiWriteFailed |
the bus wrote fewer bytes than asked |
windowOutsidePanel, unalignedWindow, wrongByteCount |
a transmit() window that doesn't fit the RAM |
partialBeforeFull |
a partial refresh before the first full one after boot |
unsupportedRefreshMode |
a partial refresh on the black/white/red panel |
invalidGeometry |
a panel larger than 176 × 296 |
invalidRegisterValue |
a register field out of range |
notConnected, incompletePin |
no bus or pin from the driver, or a pin config missing a key |
invalidSpiMode, spiClockOutOfRange, wrongSpiMode |
SPI settings the panel can't use |
invalidProperty |
an unknown property or configuration key |
SPI has no acknowledge, so a missing panel shows up as a BUSY timeout rather than a write error.
Closing
$panel->close();
close() releases DC, RST, BUSY and PWR. The SPI connection belongs to its driver and stays open for other chips. The panel keeps showing its last image.
Configuration files
config/circuits/ssd1680-bw.php and ssd1680-bwr.php share this shape:
| Key | Default | Meaning |
|---|---|---|
default_config |
'spi' |
which entry under configs conjure() uses |
configs.spi.driver |
'none' |
SPI adapter: native or usb |
configs.spi.device |
'' |
SPI bus number, or ft232h |
configs.spi.chip_select |
0 |
chip select |
configs.spi.mode |
0 |
SPI mode |
configs.spi.speed |
10_000_000 |
this chip select's clock in Hz, up to 20 MHz |
configs.spi.width / height |
null |
panel size; null keeps 122 × 250 |
configs.spi.dc / rst / busy |
pins 0 / 1 / 2 | driver, device, pin |
configs.spi.pwr |
disabled, pin 3 | enabled, driver, device, pin |
configs.*.boot_now |
true |
boot during conjure() |
Testing
composer install vendor/bin/pest
The suite runs against recording fakes of the SPI bus, the pins and a scripted BUSY line, so it needs no hardware. Both variants were also exercised on a Raspberry Pi 5 for this release, with someone watching. A LAFVIN 2.13 inch black/white HAT ran full refreshes in about 2.3 seconds, partial refreshes adding squares in about 0.6 seconds without a flash, and a full refresh clearing the panel after them. A Waveshare 2.13 inch e-Paper HAT (B) V4, black/white/red on the same pins, showed a black border and X with two red squares after an 18-second full refresh.
Security
The driver writes commands and image data to hardware the PHP process can open. See SECURITY.md for the support policy and how to report a vulnerability.
License
MIT. See LICENSE.