# SPDX-FileCopyrightText: 2017 Radomir Dopieralski for Adafruit Industries
# SPDX-FileCopyrightText: 2023 Matt Land
#
# SPDX-License-Identifier: MIT
"""
`adafruit_rgb_display.rgb`
====================================================
Base class for all RGB Display devices
* Author(s): Radomir Dopieralski, Michael McWethy, Matt Land
"""
import struct
import time
try:
from typing import Optional, Union, Tuple, List, Any, ByteString
import digitalio
import busio
from circuitpython_typing.pil import Image
except ImportError:
pass
try:
import numpy
except ImportError:
numpy = None
from adafruit_bus_device import spi_device
__version__ = "0.0.0+auto.0"
__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_RGB_Display.git"
# This is the size of the buffer to be used for fill operations, in 16-bit
# units.
_BUFFER_SIZE = 256
try:
import platform
if "CPython" in platform.python_implementation():
_BUFFER_SIZE = 320 * 240 # blit the whole thing
except ImportError:
pass
[docs]
def color565(
r: Union[int, Tuple[int, int, int], List[int]],
g: Optional[int] = 0,
b: Optional[int] = 0,
) -> int:
"""Convert red, green and blue values (0-255) into a 16-bit 565 encoding. As
a convenience this is also available in the parent adafruit_rgb_display
package namespace."""
if isinstance(r, (tuple, list)): # see if the first var is a tuple/list
if len(r) >= 3:
red, g, b = r[0:3]
else:
raise ValueError(
"Not enough values to unpack (expected 3, got %d)" % len(r)
)
else:
red = r
return (red & 0xF8) << 8 | (g & 0xFC) << 3 | b >> 3
[docs]
def image_to_data(image: Image) -> Any:
"""Generator function to convert a PIL image to 16-bit 565 RGB bytes."""
# NumPy is much faster at doing this. NumPy code provided by:
# Keith (https://www.blogger.com/profile/02555547344016007163)
data = numpy.array(image.convert("RGB")).astype("uint16")
color = (
((data[:, :, 0] & 0xF8) << 8)
| ((data[:, :, 1] & 0xFC) << 3)
| (data[:, :, 2] >> 3)
)
return numpy.dstack(((color >> 8) & 0xFF, color & 0xFF)).flatten().tolist()
[docs]
class DummyPin:
"""Can be used in place of a ``DigitalInOut()`` when you don't want to skip it."""
[docs]
def deinit(self) -> None:
"""Dummy DigitalInOut deinit"""
[docs]
def switch_to_output(
self, *, value: bool = False, drive_mode: Optional[digitalio.DriveMode] = None
) -> None:
"""Dummy switch_to_output method"""
@property
def value(self) -> bool:
"""Dummy value DigitalInOut property"""
@value.setter
def value(self, val: digitalio.DigitalInOut) -> None:
pass
@property
def direction(self) -> digitalio.Direction:
"""Dummy direction DigitalInOut property"""
@direction.setter
def direction(self, val: digitalio.Direction) -> None:
pass
@property
def pull(self) -> digitalio.Pull:
"""Dummy pull DigitalInOut property"""
@pull.setter
def pull(self, val: digitalio.Pull) -> None:
pass
[docs]
class Display: # pylint: disable-msg=no-member
"""Base class for all RGB display devices
:param width: number of pixels wide
:param height: number of pixels high
"""
_PAGE_SET: Optional[int] = None
_COLUMN_SET: Optional[int] = None
_RAM_WRITE: Optional[int] = None
_RAM_READ: Optional[int] = None
_X_START = 0 # pylint: disable=invalid-name
_Y_START = 0 # pylint: disable=invalid-name
_INIT: Tuple[Tuple[int, Union[ByteString, None]], ...] = ()
_ENCODE_PIXEL = ">H"
_ENCODE_POS = ">HH"
_DECODE_PIXEL = ">BBB"
def __init__(self, width: int, height: int, rotation: int) -> None:
self.width = width
self.height = height
if rotation not in (0, 90, 180, 270):
raise ValueError("Rotation must be 0/90/180/270")
self._rotation = rotation
self.init()
[docs]
def write(
self, command: Optional[int] = None, data: Optional[ByteString] = None
) -> None:
"""Abstract method"""
raise NotImplementedError()
[docs]
def read(self, command: Optional[int] = None, count: int = 0) -> ByteString:
"""Abstract method"""
raise NotImplementedError()
[docs]
def init(self) -> None:
"""Run the initialization commands."""
for command, data in self._INIT:
self.write(command, data)
# pylint: disable-msg=invalid-name,too-many-arguments
def _block(
self, x0: int, y0: int, x1: int, y1: int, data: Optional[ByteString] = None
) -> Optional[ByteString]:
"""Read or write a block of data."""
self.write(
self._COLUMN_SET, self._encode_pos(x0 + self._X_START, x1 + self._X_START)
)
self.write(
self._PAGE_SET, self._encode_pos(y0 + self._Y_START, y1 + self._Y_START)
)
if data is None:
size = struct.calcsize(self._DECODE_PIXEL)
return self.read(self._RAM_READ, (x1 - x0 + 1) * (y1 - y0 + 1) * size)
self.write(self._RAM_WRITE, data)
return None
# pylint: enable-msg=invalid-name,too-many-arguments
def _encode_pos(self, x: int, y: int) -> bytes:
"""Encode a position into bytes."""
return struct.pack(self._ENCODE_POS, x, y)
def _encode_pixel(self, color: Union[int, Tuple]) -> bytes:
"""Encode a pixel color into bytes."""
return struct.pack(self._ENCODE_PIXEL, color)
def _decode_pixel(self, data: Union[bytes, Union[bytearray, memoryview]]) -> int:
"""Decode bytes into a pixel color."""
return color565(*struct.unpack(self._DECODE_PIXEL, data))
[docs]
def pixel(
self, x: int, y: int, color: Optional[Union[int, Tuple]] = None
) -> Optional[int]:
"""Read or write a pixel at a given position."""
if color is None:
return self._decode_pixel(self._block(x, y, x, y)) # type: ignore[arg-type]
if 0 <= x < self.width and 0 <= y < self.height:
self._block(x, y, x, y, self._encode_pixel(color))
return None
[docs]
def image(
self,
img: Image,
rotation: Optional[int] = None,
x: int = 0,
y: int = 0,
) -> None:
"""Set buffer to value of Python Imaging Library image. The image should
be in 1 bit mode and a size not exceeding the display size when drawn at
the supplied origin."""
if rotation is None:
rotation = self.rotation
if not img.mode in ("RGB", "RGBA"):
raise ValueError("Image must be in mode RGB or RGBA")
if rotation not in (0, 90, 180, 270):
raise ValueError("Rotation must be 0/90/180/270")
if rotation != 0:
img = img.rotate(rotation, expand=True)
imwidth, imheight = img.size
if x + imwidth > self.width or y + imheight > self.height:
raise ValueError(
"Image must not exceed dimensions of display ({0}x{1}).".format(
self.width, self.height
)
)
if numpy:
pixels = bytes(image_to_data(img))
else:
# Slower but doesn't require numpy
pixels = bytearray(imwidth * imheight * 2)
for i in range(imwidth):
for j in range(imheight):
pix = color565(img.getpixel((i, j)))
pixels[2 * (j * imwidth + i)] = pix >> 8
pixels[2 * (j * imwidth + i) + 1] = pix & 0xFF
self._block(x, y, x + imwidth - 1, y + imheight - 1, pixels)
# pylint: disable-msg=too-many-arguments
[docs]
def fill_rectangle(
self, x: int, y: int, width: int, height: int, color: Union[int, Tuple]
) -> None:
"""Draw a rectangle at specified position with specified width and
height, and fill it with the specified color."""
x = min(self.width - 1, max(0, x))
y = min(self.height - 1, max(0, y))
width = min(self.width - x, max(1, width))
height = min(self.height - y, max(1, height))
self._block(x, y, x + width - 1, y + height - 1, b"")
chunks, rest = divmod(width * height, _BUFFER_SIZE)
pixel = self._encode_pixel(color)
if chunks:
data = pixel * _BUFFER_SIZE
for _ in range(chunks):
self.write(None, data)
self.write(None, pixel * rest)
# pylint: enable-msg=too-many-arguments
[docs]
def fill(self, color: Union[int, Tuple] = 0) -> None:
"""Fill the whole display with the specified color."""
self.fill_rectangle(0, 0, self.width, self.height, color)
[docs]
def hline(self, x: int, y: int, width: int, color: Union[int, Tuple]) -> None:
"""Draw a horizontal line."""
self.fill_rectangle(x, y, width, 1, color)
[docs]
def vline(self, x: int, y: int, height: int, color: Union[int, Tuple]) -> None:
"""Draw a vertical line."""
self.fill_rectangle(x, y, 1, height, color)
@property
def rotation(self) -> int:
"""Set the default rotation"""
return self._rotation
@rotation.setter
def rotation(self, val: int) -> None:
if val not in (0, 90, 180, 270):
raise ValueError("Rotation must be 0/90/180/270")
self._rotation = val
[docs]
class DisplaySPI(Display):
"""Base class for SPI type devices"""
# pylint: disable-msg=too-many-arguments
def __init__(
self,
spi: busio.SPI,
dc: digitalio.DigitalInOut,
cs: digitalio.DigitalInOut,
rst: Optional[digitalio.DigitalInOut] = None,
width: int = 1,
height: int = 1,
baudrate: int = 12000000,
polarity: int = 0,
phase: int = 0,
*,
x_offset: int = 0,
y_offset: int = 0,
rotation: int = 0
):
self.spi_device = spi_device.SPIDevice(
spi, cs, baudrate=baudrate, polarity=polarity, phase=phase
)
self.dc_pin = dc
self.rst = rst
self.dc_pin.switch_to_output(value=0)
if self.rst:
self.rst.switch_to_output(value=0)
self.reset()
self._X_START = x_offset # pylint: disable=invalid-name
self._Y_START = y_offset # pylint: disable=invalid-name
super().__init__(width, height, rotation)
# pylint: enable-msg=too-many-arguments
[docs]
def reset(self) -> None:
"""Reset the device"""
if not self.rst:
raise RuntimeError("a reset pin was not provided")
self.rst.value = 0
time.sleep(0.050) # 50 milliseconds
self.rst.value = 1
time.sleep(0.050) # 50 milliseconds
# pylint: disable=no-member
[docs]
def write(
self, command: Optional[int] = None, data: Optional[ByteString] = None
) -> None:
"""SPI write to the device: commands and data"""
if command is not None:
self.dc_pin.value = 0
with self.spi_device as spi:
spi.write(bytearray([command]))
if data is not None:
self.dc_pin.value = 1
with self.spi_device as spi:
spi.write(data)
[docs]
def read(self, command: Optional[int] = None, count: int = 0) -> ByteString:
"""SPI read from device with optional command"""
data = bytearray(count)
self.dc_pin.value = 0
with self.spi_device as spi:
if command is not None:
spi.write(bytearray([command]))
if count:
spi.readinto(data)
return data