Skip to content

Instantly share code, notes, and snippets.

@mpilone
Created April 22, 2026 14:24
Show Gist options
  • Select an option

  • Save mpilone/2302813a27636880e0a2ca3c9b499f13 to your computer and use it in GitHub Desktop.

Select an option

Save mpilone/2302813a27636880e0a2ca3c9b499f13 to your computer and use it in GitHub Desktop.
ST7735 Driver based on Pi4J
import com.pi4j.io.gpio.digital.DigitalOutput;
import com.pi4j.io.spi.Spi;
import java.awt.*;
import java.awt.image.BufferedImage;
/**
* Implementation of an ST7735 LCD driver. The driver supports initializing the display, drawing images, and
* shutting down the display. The implementation is highly inspired by the Adafruit CircuitPython library.
*
* @author mpilone
* @see <a href="http://www.adafruit.com/datasheets/ST7735R_V0.2.pdf">ST7735R Datasheet</a>
* @see <a href="https://github.com/adafruit/Adafruit-GFX-Library">Adafruit GFX Library</a>
* @see <a href="https://github.com/adafruit/Adafruit-ST7735-Library">Adafruit ST7735 Library</a>
* @since 12/19/24.
*/
public class ST7735 {
public static final int WIDTH = 128;
public static final int HEIGHT = 160;
/**
* The maximum width the ST7735. The device should be configured at the hardware level to display
* {@link #WIDTH} pixels, but the actual display RAM will store more data, and we found that it needs
* to be cleared to avoid artifacts on the screen.
*/
private static final int DISPLAY_RAM_WIDTH = 132;
/**
* The maximum height the ST7735. The device should be configured at the hardware level to display
* {@link #HEIGHT} pixels, but the actual display RAM will store more data, and we found that it needs
* to be cleared to avoid artifacts on the screen.
*/
private static final int DISPLAY_RAM_HEIGHT = 162;
/**
* The display RAM size in bytes. 18 bits per pixel per the datasheet.
*/
private static final int DISPLAY_RAM_SIZE = (DISPLAY_RAM_WIDTH * DISPLAY_RAM_HEIGHT * 18) / 8;
private final byte ST77XX_NOP = (byte) 0x00;
private final byte ST77XX_SWRESET = (byte) 0x01;
private final byte ST77XX_RDDID = (byte) 0x04;
private final byte ST77XX_RDDST = (byte) 0x09;
private final byte ST77XX_SLPIN = (byte) 0x10;
private final byte ST77XX_SLPOUT = (byte) 0x11;
private final byte ST77XX_PTLON = (byte) 0x12;
private final byte ST77XX_NORON = (byte) 0x13;
private final byte ST77XX_INVOFF = (byte) 0x20;
private final byte ST77XX_INVON = (byte) 0x21;
private final byte ST77XX_DISPOFF = (byte) 0x28;
private final byte ST77XX_DISPON = (byte) 0x29;
private final byte ST77XX_CASET = (byte) 0x2A;
private final byte ST77XX_RASET = (byte) 0x2B;
private final byte ST77XX_RAMWR = (byte) 0x2C;
private final byte ST77XX_RAMRD = (byte) 0x2E;
private final byte ST77XX_PTLAR = (byte) 0x30;
private final byte ST77XX_TEOFF = (byte) 0x34;
private final byte ST77XX_TEON = (byte) 0x35;
private final byte ST77XX_MADCTL = (byte) 0x36;
private final byte ST77XX_COLMOD = (byte) 0x3A;
private final byte ST77XX_MADCTL_MY = (byte) 0x80;
private final byte ST77XX_MADCTL_MX = (byte) 0x40;
private final byte ST77XX_MADCTL_MV = (byte) 0x20;
private final byte ST77XX_MADCTL_ML = (byte) 0x10;
private final byte ST77XX_MADCTL_RGB = (byte) 0x00;
private final byte ST77XX_RDID1 = (byte) 0xDA;
private final byte ST77XX_RDID2 = (byte) 0xDB;
private final byte ST77XX_RDID3 = (byte) 0xDC;
private final byte ST77XX_RDID4 = (byte) 0xDD;
private final byte ST7735_MADCTL_BGR = (byte) 0x08;
private final byte ST7735_MADCTL_MH = (byte) 0x04;
private final byte ST7735_FRMCTR1 = (byte) 0xB1;
private final byte ST7735_FRMCTR2 = (byte) 0xB2;
private final byte ST7735_FRMCTR3 = (byte) 0xB3;
private final byte ST7735_INVCTR = (byte) 0xB4;
private final byte ST7735_DISSET5 = (byte) 0xB6;
private final byte ST7735_PWCTR1 = (byte) 0xC0;
private final byte ST7735_PWCTR2 = (byte) 0xC1;
private final byte ST7735_PWCTR3 = (byte) 0xC2;
private final byte ST7735_PWCTR4 = (byte) 0xC3;
private final byte ST7735_PWCTR5 = (byte) 0xC4;
private final byte ST7735_VMCTR1 = (byte) 0xC5;
private final byte ST7735_PWCTR6 = (byte) 0xFC;
private final byte ST7735_GMCTRP1 = (byte) 0xE0;
private final byte ST7735_GMCTRN1 = (byte) 0xE1;
/**
* The GPIO pin corresponding to the D/C line on the display.
*/
private final DigitalOutput dcPin;
/**
* The internal SPI device.
*/
private final Spi spi;
private boolean optimizeDraw = true;
private BufferedImage cachedImg;
public ST7735(DigitalOutput rstPin, DigitalOutput dcPin, Spi spi) {
this.dcPin = dcPin;
this.spi = spi;
// Hardware reset
rstPin.high();
Threads.safeSleep(50);
rstPin.low();
Threads.safeSleep(50);
rstPin.high();
Threads.safeSleep(50);
// I'm not sure which of these initialization commands we need. This is the full list from the
// sample Adafruit code, but I don't know if they apply to all devices or just the Adafruit module.
// Commenting them out doesn't seem to make a difference, but it is possible they are already set
// on my hardware from initial testing with CircuitPython. The reference manual defines the
// defaults after power-on.
command(ST77XX_SWRESET); // 1: Software reset, 0 args, w/delay
Threads.safeSleep(150);
command(ST77XX_SLPOUT); // 2: Out of sleep mode, 0 args, w/delay
Threads.safeSleep(150);
// sendCommand(ST7735_FRMCTR1, // 3: Framerate ctrl - normal mode, 3 arg:
// 0x01, 0x2C, 0x2D); // Rate = fosc/(1x2+40) * (LINE+2C+2D)
// sendCommand(ST7735_FRMCTR2, // 4: Framerate ctrl - idle mode, 3 args:
// 0x01, 0x2C, 0x2D); // Rate = fosc/(1x2+40) * (LINE+2C+2D)
// sendCommand(ST7735_FRMCTR3, // 5: Framerate - partial mode, 6 args:
// 0x01, 0x2C, 0x2D, // Dot inversion mode
// 0x01, 0x2C, 0x2D); // Line inversion mode
// sendCommand(ST7735_INVCTR, // 6: Display inversion ctrl, 1 arg:
// 0x07); // No inversion
// sendCommand(ST7735_PWCTR1, // 7: Power control, 3 args, no delay:
// 0xA2,
// 0x02, // -4.6V
// 0x84); // AUTO mode
// sendCommand(ST7735_PWCTR2, // 8: Power control, 1 arg, no delay:
// 0xC5); // VGH25=2.4C VGSEL=-10 VGH=3 * AVDD
// sendCommand(ST7735_PWCTR3, // 9: Power control, 2 args, no delay:
// 0x0A, // Opamp current small
// 0x00); // Boost frequency
// sendCommand(ST7735_PWCTR4, // 10: Power control, 2 args, no delay:
// 0x8A, // BCLK/2,
// 0x2A); // opamp current small & medium low
// sendCommand(ST7735_PWCTR5, // 11: Power control, 2 args, no delay:
// 0x8A, 0xEE);
// sendCommand(ST7735_VMCTR1, // 12: Power control, 1 arg, no delay:
// 0x0E);
command(ST77XX_INVOFF); // 13: Don't invert display, no args
command(ST77XX_MADCTL, // 14: Mem access ctl (directions), 1 arg:
ST77XX_MADCTL_RGB); // row/col addr, top-bottom refresh
command(ST77XX_COLMOD, // 15: set color mode, 1 arg, no delay:
0x05); // 16-bit color
// command(ST7735_GMCTRP1, // 1: Gamma Adjustments (pos. polarity), 16 args, no delay:
// 0x02, 0x1c, 0x07, 0x12, // (Not entirely necessary, but provides
// 0x37, 0x32, 0x29, 0x2d, // accurate colors)
// 0x29, 0x25, 0x2B, 0x39,
// 0x00, 0x01, 0x03, 0x10);
// command(ST7735_GMCTRN1, // 2: Gamma Adjustments (neg. polarity), 16 args, no delay:
// 0x03, 0x1d, 0x07, 0x06, // (Not entirely necessary, but provides
// 0x2E, 0x2C, 0x29, 0x2D, // accurate colors)
// 0x2E, 0x2E, 0x37, 0x3F,
// 0x00, 0x00, 0x02, 0x10);
command(ST77XX_NORON); // 3: Normal display on, no args, w/delay
Threads.safeSleep(10); // 10 ms delay
clearDisplayMemory();
setRotation(0);
command(ST77XX_DISPON); // 4: Main screen turn on, no args w/delay
Threads.safeSleep(10); // 10 ms delay
}
/**
* Enables the optimized draw mode when drawing. In this mode, a cached version of the display image is kept in
* memory and on redraw, only modified blocks are send to the display. This increases the load on the Java side
* but it can greatly reduce the amound of data and redrawing on the display hardware which can help eliminate
* flicker in high FPS applications. Defaults to true (enabled).
*
* @param optimizeDraw true to enable optimized drawing, false to always draw the provided image
*/
public void setOptimizeDraw(boolean optimizeDraw) {
this.optimizeDraw = optimizeDraw;
}
/**
* Convenience method for {@link #drawImage(BufferedImage, int, int)} that assumes a (0, 0) starting position.
*
* @param img the image to draw to the display
*/
public void drawImage(BufferedImage img) {
drawImage(img, 0, 0);
}
/**
* Draws the entire given image to the display, starting at the given coordinates on the display. If
* {@link #setOptimizeDraw(boolean)} is enabled, only the modified portions of the image are written to the
* display. The image must be type {@link BufferedImage#TYPE_USHORT_565_RGB}.
*
* @param img the image to draw to the display
* @param x the start x coordinate on the display
* @param y the start y coordinate on the display
*/
public void drawImage(BufferedImage img, int x, int y) {
if (img.getType() != BufferedImage.TYPE_USHORT_565_RGB) {
throw new IllegalArgumentException("Image type must be 565 RGB.");
}
if (optimizeDraw) {
drawImageDelta(img, x, y);
}
else {
imageRamWrite(img, x, y);
}
}
/**
* Writes the given image to the display RAM, starting at the given coordinates using the appropriate CASET,
* RASET, and RAMWR commands. The entire image is written,
* therefore, the image should be resized/sliced before calling this method if only a sub-image should be written.
*
* @param img the image to write
* @param x the start x coordinate on the display
* @param y the start y coordinate on the display
*/
private void imageRamWrite(BufferedImage img, int x, int y) {
if (img.getType() != BufferedImage.TYPE_USHORT_565_RGB) {
throw new IllegalArgumentException("Image type must be 565 RGB.");
}
// We can't convert the raster data directly because sub-images may be backed by larger
// arrays. This is a trade-off when using BufferedImage.getSubimage which shares the backing
// data buffer with the original image. We could write this image
// to a new buffered image and then extract the raster, but I'm assuming that would
// be slower because it requires an extra raster data buffer creation and data copy. However, fetching
// pixel by pixel is probably the same amount of work. In either case, it's such a small amount of
// data it probably doesn't matter.
// WritableRaster raster = img.getRaster();
// DataBufferUShort buffer = (DataBufferUShort) raster.getDataBuffer();
// short[] imgData = buffer.getData();
//
// for (int i = 0; i < imgData.length; i++) {
// data[i * 2] = (byte) ((imgData[i] >> 8) & 0xFF);
// data[i * 2 + 1] = (byte) (imgData[i] & 0xFF);
// }
// System.out.printf("Drawing block. x=%d, y=%d, width=%d, height=%d\n",
// x, y, img.getWidth(), img.getHeight());
byte[] data = new byte[img.getWidth() * img.getHeight() * 2];
int dataIndex = 0;
for (int row = 0; row < img.getHeight(); row++) {
for (int col = 0; col < img.getWidth(); col++) {
// 888
int rgb = img.getRGB(col, row);
byte r = (byte) ((rgb & 0xFF0000) >> 16);
byte g = (byte) ((rgb & 0xFF00) >> 8);
byte b = (byte) ((rgb & 0xFF));
// 565
rgb = ((r >> 3) & 0x1f) << 11;
rgb |= ((g >> 2) & 0x3f) << 5;
rgb |= (b >> 3) & 0x1f;
data[dataIndex++] = (byte) ((rgb >> 8) & 0xFF);
data[dataIndex++] = (byte) (rgb & 0xFF);
}
}
command(ST77XX_CASET, 0x00, x, 0x00, x + img.getWidth() - 1);
command(ST77XX_RASET, 0x00, y, 0x00, y + img.getHeight() - 1);
command(ST77XX_RAMWR);
data(data);
}
/**
* Draws only the changed blocks of the given image to the display. Blocks that have not changed will not be sent
* to the display.
*
* @param img the image to draw
* @param x the start x coordinate on the display
* @param y the start y coordinate on the display
*/
private void drawImageDelta(BufferedImage img, int x, int y) {
// Arbitrary block size. Smaller blocks mean more writes, but ideally fewer changed blocks.
// Larger blocks mean fewer writes, but more changes in a single block.
final int blockSize = 10;
for (int blockY = 0; blockY < img.getHeight(); blockY += blockSize) {
int blockHeight = Math.min(blockSize, img.getHeight() - blockY);
for (int blockX = 0; blockX < img.getWidth(); blockX += blockSize) {
int blockWidth = Math.min(blockSize, img.getWidth() - blockX);
BufferedImage imgBlock = img.getSubimage(blockX, blockY, blockWidth, blockHeight);
BufferedImage cachedImgBlock = cachedImg.getSubimage(x + blockX, y + blockY, blockWidth, blockHeight);
if (!imgEquals(imgBlock, cachedImgBlock)) {
// Blocks differ.
// Write it to the hardware.
imageRamWrite(imgBlock, x + blockX, y + blockY);
// Update the cached image.
Graphics2D g = cachedImgBlock.createGraphics();
g.drawImage(imgBlock, 0, 0, null);
g.dispose();
}
}
}
}
/**
* Returns true if the two images are equal. That is, they contain the same pixel data.
*
* @param img1 the first image to compare
* @param img2 the second image to compare
* @return true if equal, false otherwise
*/
private static boolean imgEquals(BufferedImage img1, BufferedImage img2) {
if (img1.getWidth() != img2.getWidth() || img1.getHeight() != img2.getHeight()) {
return false;
}
for (int x = 0; x < img1.getWidth(); x++) {
for (int y = 0; y < img1.getHeight(); y++) {
int rgb1 = img1.getRGB(x, y);
int rgb2 = img2.getRGB(x, y);
if (rgb1 != rgb2) {
// Stop comparing as soon as we find a difference.
return false;
}
}
}
return true;
// We can't compare the raster data directly because sub-images may be backed by larger
// arrays. This is a trade-off when using BufferedImage.getSubimage which shares the backing
// data buffer with the original image.
// WritableRaster raster = img1.getRaster();
// DataBufferUShort buffer = (DataBufferUShort) raster.getDataBuffer();
// short[] img1Data = buffer.getData();
//
// raster = img2.getRaster();
// buffer = (DataBufferUShort) raster.getDataBuffer();
// short[] img2Data = buffer.getData();
//
// return Arrays.equals(img1Data, img2Data);
}
/**
* Convenience method for {@link #command(byte, byte...)} that takes the lowest byte of each integer argument.
* The upper 3 bytes of the integer are ignored.
*
* @param command the command to send
* @param args the arguments for the command
*/
private void command(byte command, int... args) {
byte[] byteArgs = new byte[args.length];
for (int i = 0; i < args.length; i++) {
byteArgs[i] = (byte) args[i];
}
command(command, byteArgs);
}
/**
* Sends the given command to the display, followed by the command arguments as data.
*
* @param command the command to send
* @param args the arguments for the command
*/
private void command(byte command, byte... args) {
dcPin.low();
spi.write(command);
if (args != null && args.length > 0) {
data(args);
}
}
/**
* Sends the data to the display.
*
* @param data the data bytes
*/
private void data(byte[] data) {
// System.out.println("Sending data: " + data.length);
dcPin.high();
// Default Pi5 SPI buffer size is 4096. We might want to add some kind of support for detecting
// or querying the SPI for its buffer size.
// See https://github.com/dotnet/iot/issues/997#issuecomment-598228905
int len;
for (int offset = 0; offset < data.length; offset += len) {
len = Math.min(4096, data.length - offset);
spi.write(data, offset, len);
}
}
/**
* Sets the rotation of the display. Once rotated, the width and height of the display may be swapped. All draw
* operations must use the appropriately rotated width and height. For example, in the default rotation (0),
* width = {@link #WIDTH}. When rotated (90), width = {@link #HEIGHT}.
*
* @param rotation the rotation angle (0, 90, 180, or 270)
*/
public void setRotation(int rotation) {
int madctl;
int rotatedWidth;
int rotatedHeight;
switch (rotation) {
case 0 -> {
madctl = ST77XX_MADCTL_RGB;
rotatedWidth = WIDTH;
rotatedHeight = HEIGHT;
}
case 90 -> {
madctl = ST77XX_MADCTL_MX | ST77XX_MADCTL_MV | ST77XX_MADCTL_RGB;
rotatedWidth = HEIGHT;
rotatedHeight = WIDTH;
}
case 180 -> {
madctl = ST77XX_MADCTL_MX | ST77XX_MADCTL_MY | ST77XX_MADCTL_RGB;
rotatedWidth = WIDTH;
rotatedHeight = HEIGHT;
}
case 270 -> {
madctl = ST77XX_MADCTL_MY | ST77XX_MADCTL_MV | ST77XX_MADCTL_RGB;
rotatedWidth = HEIGHT;
rotatedHeight = WIDTH;
}
default -> throw new IllegalArgumentException("Rotation must be 0, 90, 180, or 270.");
}
command(ST77XX_MADCTL, madctl);
cachedImg = new BufferedImage(rotatedWidth, rotatedHeight, BufferedImage.TYPE_USHORT_565_RGB);
var gfx = this.cachedImg.getGraphics();
gfx.setColor(Color.WHITE);
gfx.fillRect(0, 0, rotatedWidth, rotatedHeight);
// Update the screen to match the cached image.
imageRamWrite(cachedImg, 0, 0);
}
/**
* Sets if the display is on, that is, rendering pixels. Defaults to true (on).
*
* @param displayOn true to turn the display on and render pixels, false to blank the screen
*/
public void setDisplayOn(boolean displayOn) {
command(displayOn ? ST77XX_DISPON : ST77XX_DISPOFF);
}
/**
* Sets if the display is in sleep (low power) mode. Defaults to false (sleep out).
*
* @param sleepIn true to go into sleep mode, false to come out of sleep mode
*/
public void setSleepIn(boolean sleepIn) {
command(sleepIn ? ST77XX_SLPIN : ST77XX_SLPOUT);
}
/**
* Reset the display data RAM to 0 (black). If the RAM isn't reset, we've seen displays
* show random noise until the first display is written. The full available memory on
* the device is cleared, even if we're only using a smaller display. For example, some ST7735
* displays support 132x162 resolution, but will be configured for 128x160 at the hardware level.
* If the entire display data RAM isn't cleared, we've seen pixel noise on the edges of the screen.
* We haven't seen an issue writing this memory size to all displays, even if they don't advertise
* the larger size memory, but we should keep this in mind if the display doesn't prevent memory
* overflows by ignoring data beyond the display data RAM size.
*/
private void clearDisplayMemory() {
byte[] data = new byte[DISPLAY_RAM_SIZE];
command(ST77XX_CASET, 0x00, 0, 0x00, DISPLAY_RAM_WIDTH - 1);
command(ST77XX_RASET, 0x00, 0, 0x00, DISPLAY_RAM_HEIGHT - 1);
command(ST77XX_RAMWR);
data(data);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment