Intro to the RA8875 Driver Board for Circuit Python
The Adafruit RA8875 driver in Circuit Python does not currently support displayio. You must use read/write registers with a barebones ra8875 graphics library. The current feature set and how it is used is only for very advanced users.
You can draw a bmp image and overlay text but you'll quickly find that's about all you can do with it. There are only 2 examples provided and the driver board is unlike any other display device for Circuit Python. Any knowledge you have of displayio does not transfer over to this board; the RA8875 is unique.
The interest of using an 800x480 bare display with Circuit Python is typically due to the sheer size of it but it should come with fair warning: You must be capable of programming with circuit python from scratch without displayio.
Hardware:
Software:
- Circuit Python (current stable as of this note is 9.0.4)
- adafruit_ra8875 library
Wiring
From left to right on the RA8875 driver board:
- VCC to USB/VBUS
- GND to GND
- 3VO (unused)
- LITE to 3V3
- SCK to SCK
- MISO to MISO
- MOSI to MOSI
- CS to D9
- RST to D10
- WAIT (unused)
- INT to A5
- Y+ (unused)
- Y- (unused)
- X- (unused)
- X+ (unused)
You can power the RA8875 using either the 5V USB/VBUS pin or the 3V3 pin. VCC is both 5V and 3.3V compliant. You can optionally choose to wire the LITE pin to a GPIO for use with PWM for backlight brightness control. If you're not interested in backlight brightness control then wire LITE to 3V3 for full brightness all the time.
There is an optional hardware WAIT pin that can be used to signal the beginning/end of a read/write cycle. It communicates a busy signal that an uninterruptible process is taking place for the purpose of refining timings. There is a software version in the driver that is used as default which is why it is optional.
The INT pin is for used touchscreen capability instead of the X,Y pins. It is not used in this project but it's good to hook up now. The INT pin will need to be defined for any touch related projects.
The RST pin is a hardware reset. This is optional as well as there is a software defined reset as default. The RA8875 is automatically reset whenever the Feather is reset either with a soft-reload or hard reset.
The CS pin is required and not optional. CS stands for Chip Select and basically provides the hardware address for the Feather to communicate with.
For now only 2 pins need to be defined in software. The rest are implied with the use of busio such as CLK, MOSI, MISO.
import busio import digitalio import board # Configuration for CS and RST pins: cs_pin = digitalio.DigitalInOut(board.D9) rst_pin = digitalio.DigitalInOut(board.D10 # Setup SPI bus using hardware SPI: spi = busio.SPI(clock=board.SCK, MOSI=board.MOSI, MISO=board.MISO)
The Problem
The first hurdle; the current RA8875 library for Circuit Python cannot read a pixel from the display. This is fairly important for buffering a section of the background to be replaced when you want to remove text. Currently text cannot be removed only redrawn which ends up as a jumbled mess. Most workarounds usually involve drawing a black rectangle which means your background has to be black... but what if you want a large image or multiple layers as a background? That's a problem.
OK... so let's attempt to read a single pixel from the display, translate the color, and draw it. That's the single most basic premise for being able to create layers. Without that basic functionality, more advanced functionality cannot exist yet. Yes the display has built-in methods to do this but they do not, and cannot, work as displayio does so you will be disappointed. Also, they will not be compatible for a possible displayio future implementation so it's best not to rely on them right now. The library is still in too early in its development to even think about integrating them yet.
Here is a 24-bit color test BMP that I designed specifically for attempting to sample colors. There are 12 53x53 squares of different colors in the image.
The bits and bytes of binary color
Since the RA8875 can only display a maximum of 16-bit color; the 24-bit image must be converted to 16-bit (color565).
The RA8875 stores color information in its memory with 2 bytes (2 pairs of 8 bits). Here is a binary representation of how it stores the color. 11111111 00000000 There are 8 bits in 1 byte.
However the RA8875 actually stores them in what is known as swapped color565. Each first byte must be swapped with the 2nd. This is not some type of color conversion error. These are the direct reads from the memory addresses for the stored colors.
# Please note the first read is a dummy read and is to be discarded. dummy = self._read_data16(debug=False) # Dummy read msb = self._read_data16(debug=False) # MSB read
This is how the returned binary data from the read register will look with the dummy read and the actual color you want. Here is an example for the color blue (0,0,255)
- _read_data16: (data[0]: 11100000 00000000 data[1]: 00000000 00000000) # dummy value
_read_data16: (data[0]: 00011111 00000000 data[1]: 00000000 00000000) # real value
These represent 3 consecutive reads for the colors Red, Green, and then Blue (dummy values discarded)
- 00000000 11111000
11100000 00000111
00011111 00000000
I'm using an excellent online 565 RGB Color Picker tool to verify the binary values.
If you attempt to convert these colors to 565 directly you'll notice that the colors are all wrong.
- Red is showing up as blue
- Green is showing up as red
- Blue is showing up as green
This is because the RA8875 actually stores the 2 bytes of color values swapped. This is known as swapped 565.
To fix this all we have to do is swap byte 1 with byte 2 for each color. We accomplish this by reading each 8-bit value separately and storing it in a variable.
- data[0] = 00011111
- data[1] = 00000000
We then join them back together in the opposite order (without a space)
data = data[1] data[0}
They should now be a single 16-bit binary number (this represents blue) 0000000000011111 which can be correctly converted by a color565 operation. You can choose to use a swapped 565 instead but most people are used to working with R,G,B values (not B,R,G). This is part of the goal.
The 16-bit color is split into 2 separate 8-bit registers. This is why reading it twice is necessary to extract the full 16-bit color. After swapping the bytes and combining them they are now in the expected binary format for color565 (65K colors).
- 11111000 00000000 = (255,0,0) Red
00000111 11100000 = (0,255,0) Green
00000000 00011111 = (0,0,255) Blue
# Extract color components from MSB green = (msb >> 8) & 0xFF # Extracting 8 bits of green component blue = (msb >> 3) & 0xFF # Extracting 8 bits of blue component red = (msb << 3) & 0xFF # Extracting 8 bits of red component
Figuring out how the RA8875 stores colors is just the first step to solving the goal of this playground note which is to read a pixel from the display and return it. Now that we know how it stores colors we need to be able to retrieve a color at the exact x,y coordinates of the color test BMP. We can accomplish this by using the RA8875 registers for setting an active read memory cursor at the location to be sampled.
Here is a general outline of how I accomplished it.
- Set the display in graphics or text mode (both can use read cursors)
- Write the register to set RCURH0 (Read Cursor Horizontal Layer 0) (x position)
- Write the register to set RCURV0 (Read Cursor Vertical Layer 0) (y position)
- Activate the MRWC (Memory Read/Write Control)
- Read and discard the first returned 16-bit dummy value (noted in the datasheet)
- Read the 1st byte for the color at the x,y cursor position
- Read the 2nd byte for the color at the x,y cursor position
- Swap the 1st byte and 2nd byte to produce the correct 16-bit order.
- Extract the 16-bit value into 3 separate R,G,B values
Here is the function I wrote for the adafruit_ra8875 library to add the capability to read a single pixel value. Please keep in mind it took me almost 2 weeks to learn how to work with the display driver in order to make this functionality a reality. There is a learning curve coming from displayio displays to this completely different display.
def read_single_pixel(self, x: int, y: int, debug: bool = False) -> int: """ Read the color of a single pixel from layer 0 of the display at x,y coordinates. :param int x: X coordinate of the pixel. :param int y: Y coordinate of the pixel. :param bool debug: Flag to enable debug printing. :return: Color of the pixel in RGB format (24-bit). :rtype: int """ # Ensure x and y are within display bounds if not (0 <= x < self.width and 0 <= y < self.height): raise ValueError(f"Coordinates ({x}, {y}) out of bounds for display size {self.width}x{self.height}") if debug: print(f"Reading pixel at ({x}, {y})") # Set graphics mode self._gfx_mode() # Set read cursor position for layer0 (800x480 8-bit bus mode 16-bit color mode) self._write_reg16(reg.RCURH0, x, debug=False) self._write_reg16(reg.RCURV0, y + self.vert_offset, debug=False) # Set Memory Read/Write Control self._read_reg(reg.MRWC) # Read the color data dummy = self._read_data16(debug=False) # Dummy read msb = self._read_data16(debug=False) # MSB read # Extract color components from MSB green = (msb >> 8) & 0xFF # Extracting 8 bits of green component blue = (msb >> 3) & 0xFF # Extracting 8 bits of blue component red = (msb << 3) & 0xFF # Extracting 8 bits of red component if debug: print(f"Dummy: {dummy} MSB: {msb}") print(f"Extracted Colors: {red}, {green}, {blue}") print(f"Binary values: Red: {red:08b}, Green: {green:08b}, Blue: {blue:08b}") return red, green, blue
It's a good idea to draw a cursor on the exact coordinates to be read. So I also created a function to draw a cross-hair cursor. One already exists in the built-in functions but it's not a 2-color cursor. This ensures that the x,y coordinates you want to sample are being shared to the cursor for sampling. It should be obviously important to do the pixel read prior to drawing the cursor. ;)
It's a 2 color cursor in case the color you're sampling is the same color as the cross-hair. You'll still be able to see some form of a cross-hair regardless of the background color. I'm using a red crosshair with black outline but the colors in the function are configurable.
def draw_cursor(self, x, y, inside_color, outline_color, debug: bool = False): # Draw the outline of the cursor for i in range(-4, 5): self.pixel(x + i, y - 4, outline_color) # Top line self.pixel(x + i, y + 4, outline_color) # Bottom line self.pixel(x - 4, y + i, outline_color) # Left line self.pixel(x + 4, y + i, outline_color) # Right line # Draw the inside of the cursor for i in range(-3, 4): self.pixel(x + i, y, inside_color) # Horizontal line self.pixel(x, y + i, inside_color) # Vertical line if debug: print(f"Drawing Cursor at: {x},{y}")
As you can see in the red squares the cross-hair would get lost on a red background. A 2-color cross-hair makes moving and finding the x,y coordinate you want to sample much easier.
Keep in mind these rectangles are from the color test BMP image and not vector rectangles drawn by the display. So if we can accurately read these colors then we'll know we're actually reading a pixel from the display itself.
Instead of reading 1 individual pixel I want to read 12 individual pixels to ensure the function can be repeatedly used as intended.
So how to confirm the pixel values we're reading are actually correct? We can use the adafruit_ra8875 library existing fill_rect function to replicate the 53x53 rectangles to another portion of the display.
code.py example # This will draw a 53x53 red rectangle beginning at coordinates 0,0 display.fill_rect(0, 0, 53, 53, color565(255,0,0)
But we don't want to draw just 1 rectangle, we need all 12 to confirm the replication. So we just repeat this 12 times at the appropriate coordinates. You could use a for loop to do this but I've discovered a for loop is actually much slower than just repeating it manually 12 times. It is about 12 times slower actually. By manually repeating the fill_rect command 12 times in code.py the draw time is almost mind-blowing instantaneous! Something to keep in mind when using for loops with this display. It definitely prefers sequential commands vs for loops.
Here is an early attempt at a failed color conversion. Ultimately the issue ended up being the way the BMP itself was encoded. That's another thing to note is that not all BMP's are the same. Windows will convert a BMP differently than GIMP or an online BMP converter.
Eventually I had to add in a lot of debugging prints to the function and other functions to figure out why the color wasn't reading correctly or if it could actually read the display at all. I went through days of this and almost gave up many times. Eventually I started printing the binary values directly and that's when I noticed the discrepancy between the binary and color565 values.
Eventually I did get it to work and it works quite well. The color reproduction isn't 100% accurate due to 24-bit to 16-bit conversion but that's a refinement for another day.
Here is the sum of my efforts. A couple of new functions for the library and a code.py example to read pixels from the RA8875 display with fill_rect to confirm their values.
# SPDX-FileCopyrightText: 2024 DJDevon3 # SPDX-License-Identifier: MIT """RA8875 Read Single Pixel example""" import time import busio import digitalio import board from adafruit_ra8875 import ra8875 from adafruit_ra8875.bmp import BMP from adafruit_ra8875.ra8875 import color565 # Configuration for CS and RST pins: cs_pin = digitalio.DigitalInOut(board.D9) rst_pin = digitalio.DigitalInOut(board.D10) # Config for display baudrate (default max is 6mhz): BAUDRATE = 6000000 # Setup SPI bus using hardware SPI: spi = busio.SPI(clock=board.SCK, MOSI=board.MOSI, MISO=board.MISO) # Create and setup the RA8875 display: display = ra8875.RA8875(spi, cs=cs_pin, rst=rst_pin, baudrate=BAUDRATE) display.init() BLACK = color565(0, 0, 0) RED = color565(255, 0, 0) GREEN = color565(0, 255, 0) BLUE = color565(0, 0, 255) YELLOW = color565(255, 255, 0) CYAN = color565(0, 255, 255) MAGENTA = color565(255, 0, 255) WHITE = color565(255, 255, 255) # Load the bitmap image bitmap = BMP("/Color_test_chart2.bmp") # Center BMP image on the display x_position = (display.width // 2) - (bitmap.width // 2) y_position = (display.height // 2) - (bitmap.height // 2) # Fill entire display background with white display.fill(WHITE) print(f"Filled display layer0 with white\n") # Draw BMP (bottom to top) bitmap.draw_bmp(display, x_position, y_position) # Coordinates inside of a 53x53 red square in the bmp X1 = 320 Y1 = 190 # Coordinates inside of a 53x53 blue square in the bmp X2 = 370 Y2 = 190 # Coordinates inside of a 53x53 purple square in the bmp X3 = 425 Y3 = 190 # Coordinates inside of a 53x53 yellow square in the bmp X4 = 485 Y4 = 190 # Coordinates inside of a 53x53 green square in the bmp X5 = 320 Y5 = 240 # Coordinates inside of a 53x53 cyan square in the bmp X6 = 370 Y6 = 240 # Coordinates inside of a 53x53 white square in the bmp X7 = 425 Y7 = 240 # Coordinates inside of a 53x53 black square in the bmp X8 = 485 Y8 = 240 # Coordinates inside of a 53x53 yellow square in the bmp X9 = 320 Y9 = 290 # Coordinates inside of a 53x53 red square in the bmp X10 = 370 Y10 = 290 # Coordinates inside of a 53x53 green square in the bmp X11 = 425 Y11 = 290 # Coordinates inside of a 53x53 blue square in the bmp X12 = 485 Y12 = 290 # List of color sampling coordinates coordinates = [ (X1, Y1), (X2, Y2), (X3, Y3), (X4, Y4), (X5, Y5), (X6, Y6), (X7, Y7), (X8, Y8), (X9, Y9), (X10, Y10), (X11, Y11), (X12, Y12) ] # Giving them names makes it easier to spot errors color_names = [ "Red", "Blue", "Purple", "Yellow", "Green", "Cyan", "White", "Black", "Yellow", "Red", "Green", "Blue" ] # Starting x,y for color rectangles to create rect_coordinates = [ (0, 0), (53, 0), (106, 0), (159, 0), (0, 53), (53, 53), (106, 53), (159, 53), (0, 106), (53, 106), (106, 106), (159, 106) ] # Read every pixel at listed coordinates # Returns colors as r,g,b # Creates filled rectangles using r,g,b to confirm color sample for i, (x, y) in enumerate(coordinates): color = display.read_single_pixel(x, y) print(f"color{i+1} at ({x},{y}): {color_names[i]} - {color}") time.sleep(0.1) rect_x, rect_y = rect_coordinates[i] display.fill_rect(rect_x, rect_y, 53, 53, color565(color)) # Draws cross-hair to confirm sampled coordinates # This can only happen after the sample is taken display.draw_cursor(X1,Y1,RED,BLACK) display.draw_cursor(X2,Y2,RED,BLACK) display.draw_cursor(X3,Y3,RED,BLACK) display.draw_cursor(X4,Y4,RED,BLACK) display.draw_cursor(X5,Y5,RED,BLACK) display.draw_cursor(X6,Y6,RED,BLACK) display.draw_cursor(X7,Y7,RED,BLACK) display.draw_cursor(X8,Y8,RED,BLACK) display.draw_cursor(X9,Y9,RED,BLACK) display.draw_cursor(X10,Y10,RED,BLACK) display.draw_cursor(X11,Y11,RED,BLACK) display.draw_cursor(X12,Y12,RED,BLACK)
Now that the RA8875 library can read single pixels it will open the door for others in the future to build more complex features and perhaps someday... a hardware accelerated version of displayio.