diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..0a4ec3d --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,29 @@ +name: Publish to PyPI + +on: + push: + tags: + - "v*" + +jobs: + build-and-publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Build package + run: uv build + + - name: Upload distributions + uses: actions/upload-artifact@v4 + with: + name: release-dists + path: dist/ + + - name: Publish to PyPI + run: uv publish --token ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index 04a9124..9c7974a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ # Python files __pycache__/ +dist/ +uv.lock diff --git a/ILI9486.py b/ILI9486.py deleted file mode 100644 index d13b8eb..0000000 --- a/ILI9486.py +++ /dev/null @@ -1,299 +0,0 @@ -# Copyright (c) -# Authors: Tony DiCola, Liqun Hu, Thorben Yzer -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -from enum import Enum -import time -import numpy as np -from PIL import Image, ImageDraw -import RPi.GPIO as GPIO -from spidev import SpiDev - -# constants -LCD_WIDTH = 320 -LCD_HEIGHT = 480 - -# commands -CMD_RDPXLFMT = 0x0C - -CMD_SLPIN = 0x10 -CMD_SLPOUT = 0x11 - -CMD_INVOFF = 0x20 -CMD_INVON = 0x21 -CMD_DISPOFF = 0x28 -CMD_DISPON = 0x29 - -CMD_SETCA = 0x2A -CMD_SETPA = 0x2B -CMD_WRMEM = 0x2C -CMD_RDMEM = 0x2E - -CMD_MADCTL = 0x36 -CMD_IDLOFF = 0x38 -CMD_IDLON = 0x39 -CMD_PXLFMT = 0x3A - -CMD_IFMODE = 0xB0 - -CMD_PWRCTLNOR = 0xC2 -CMD_VCOMCTL = 0xC5 - -CMD_PGAMCTL = 0xE0 -CMD_NGAMCTL = 0xE1 - - -def image_to_data(image: Image) -> object: - """Converts a PIL image to 666RGB format that can be drawn on the LCD.""" - pb = np.array(image.convert('RGB')).astype('uint16') - # cut of the two least significant / rightmost bits to convert 8-bit color to 6-bit color - return np.dstack((pb[:, :, 0] & 0xFC, pb[:, :, 1] & 0xFC, pb[:, :, 2] & 0xFC)).flatten().tolist() - - -class Origin(Enum): - """Representation of the display origin. The origin is defined by the position of the image relative to the default - orientation of the raspberry. The default orientation has the GPIO pins being on top, so that the raspberry logo - and the model's name are readable. The reference point is then the upper left corner, where GPIO pin 1 is located. - The origin UPPER_LEFT is the default origin, because the reference point is in the upper left corner.""" - - # data: MY | MX | MV | ML | BGR | MH | X | X - # The MV bit controls the format. 0 means portrait mode, 1 means landscape mode. - # The BGR bit is a bit weird. 0 means RGB mode, 1 means BGR mode. However, we always set it to 1 / BGR, despite - # using RGB pixel format. Maybe the documentation is wrong here. - UPPER_LEFT = 0x28 - UPPER_LEFT_MIRRORED = 0xA8 - LOWER_LEFT = 0x48 - LOWER_LEFT_MIRRORED = 0x08 - UPPER_RIGHT = 0x88 - UPPER_RIGHT_MIRRORED = 0xC8 - LOWER_RIGHT = 0xE8 - LOWER_RIGHT_MIRRORED = 0x68 - - -class ILI9486: - """Representation of an ILI9486 TFT.""" - - @classmethod - def landscape_dimensions(cls) -> tuple: - """Returns the display dimensions in landscape mode, no matter what mode is used""" - return LCD_HEIGHT, LCD_WIDTH - - @classmethod - def portrait_dimensions(cls) -> tuple: - """Returns the display dimensions in portrait mode, no matter what mode is used""" - return LCD_WIDTH, LCD_HEIGHT - - def __init__(self, spi: SpiDev, dc: int, rst: int = None, *, origin: Origin = Origin.UPPER_LEFT): - """Creates an instance of the display using the given SPI connection. Must provide the SPI driver and the GPIO - pin number for the DC pin. Can optionally provide the GPIO pin number for the reset pin. Optionally the origin - can be set. The default is UPPER_LEFT, which is landscape mode this the bottom of the image located at the - power, video and audio out are of the Pi.""" - self.__spi = spi - self.__dc = dc - self.__rst = rst - self.__origin = origin - self.__width = LCD_WIDTH - self.__height = LCD_HEIGHT - self.__inverted = False - self.__idle = False - - GPIO.setmode(GPIO.BCM) - GPIO.setup(self.__dc, GPIO.OUT) - GPIO.output(self.__dc, GPIO.HIGH) - if self.__rst is not None: - GPIO.setup(self.__rst, GPIO.OUT) - GPIO.output(self.__rst, GPIO.HIGH) - - # swap width and height if selected origin is landscape mode by checking if third bit is 1 - if self.__origin.value & 0x20: - self.__width, self.__height = self.__height, self.__width - self.__buffer = Image.new('RGB', (self.__width, self.__height), (0, 0, 0)) - - def dimensions(self) -> tuple: - """Returns the current display dimensions""" - return self.__width, self.__height - - def is_landscape(self) -> bool: - """Returns true if selected origin is landscape mode; false otherwise""" - return bool(self.__origin.value & 0x20) - - def send(self, data, is_data=True, chunk_size=4096): - """Writes a byte or an array of bytes to the display.""" - # dc low for command, high for data - GPIO.output(self.__dc, is_data) - if isinstance(data, int): - self.__spi.writebytes([data]) - else: - for start in range(0, len(data), chunk_size): - end = min(start + chunk_size, len(data)) - self.__spi.writebytes(data[start: end]) - return self - - def command(self, data): - """Writes a byte or an array of bytes to the display as a command.""" - return self.send(data, False) - - def data(self, data): - """Writes a byte or an array of bytes to the display as data.""" - return self.send(data, True) - - def reset(self): - """Resets the display if a reset pin is provided.""" - if self.__rst is not None: - GPIO.output(self.__rst, GPIO.HIGH) - time.sleep(.001) # wait a bit to make sure the output was HIGH - GPIO.output(self.__rst, GPIO.LOW) - time.sleep(.000100) # wait 100 µs to trigger the reset (should be 10 µs, but the OS is not precise enough) - GPIO.output(self.__rst, GPIO.HIGH) - time.sleep(.120) # wait 120 ms for finishing blanking and resetting - self.__inverted = False - self.__idle = False - return self - - def _init_sequence(self): - """Initializes the display. Protected in case you want to override it for e.g. gamma control""" - self.command(CMD_IFMODE).data(0x00) - self.command(CMD_SLPOUT) # turns off the sleep mode - time.sleep(0.020) - - self.command(CMD_PXLFMT).data(0x66) # 18 bits per pixel - self.command(CMD_RDPXLFMT).data(0x66) # 18 bits per pixel - - self.command(CMD_PWRCTLNOR).command(0x44) - - self.command(CMD_VCOMCTL).send([0x00, 0x00, 0x00, 0x00], True, chunk_size=1) - - self.command(CMD_PGAMCTL)\ - .send([0x0F, 0x1F, 0x1C, 0x0C, 0x0F, 0x08, 0x48, 0x98, 0x37, 0x0A, 0x13, 0x04, 0x11, 0x0D, 0x00], True, - chunk_size=1) # values must be sent one by one, thus setting chunk size to 1 - - self.command(CMD_NGAMCTL)\ - .send([0x0F, 0x32, 0x2E, 0x0B, 0x0D, 0x05, 0x47, 0x75, 0x37, 0x06, 0x10, 0x03, 0x24, 0x20, 0x00], True, - chunk_size=1) # values must be sent one by one, thus setting chunk size to 1 - - self.command(CMD_MADCTL).data(self.__origin.value) # memory address control - - self.command(CMD_SLPOUT) - self.command(CMD_DISPON) - return self - - def begin(self): - """Initializes the display by resetting it and calling the init sequence.""" - return self.reset()._init_sequence() - - def set_window(self, x0=0, y0=0, x1=None, y1=None): - """Sets the pixel address window for proceeding drawing commands.""" - if x1 is None: - x1 = self.__width - 1 - if y1 is None: - y1 = self.__height - 1 - self.command(CMD_SETCA) # column address - self.data(x0 >> 8) - self.data(x0 & 0xFF) - self.data(x1 >> 8) - self.data(x1 & 0xFF) - self.command(CMD_SETPA) # page address / row address - self.data(y0 >> 8) - self.data(y0 & 0xFF) - self.data(y1 >> 8) - self.data(y1 & 0xFF) - return self - - def display(self, image=None, x0 = 0, y0 = 0): - """Writes the display buffer or provided image to the display. If no - image is provided the display buffer will be written to the display. - If an image is provided, it should be in RGB format and the same - dimensions as the display.""" - if image is None: - image = self.__buffer - width, height = image.size - x1 = x0 + width - 1 - y1 = y0 + height - 1 - if image.mode != 'RGB': - raise ValueError('Image must be in RGB format') - if x1 >= self.__width or y1 >= self.__height or x0 < 0 or y0 < 0: - raise ValueError( - 'Image exceeds display bounds ({0}x{1})'.format(self.__width, self.__height)) - self.set_window(x0, y0, x1, y1) - data = image_to_data(image) - self.command(CMD_WRMEM) - if isinstance(data, list): - self.data(list(data)) - return self - - def clear(self, color=(0, 0, 0)): - """Clears the image buffer to the specified RGB color or black if not provided.""" - width, height = self.__buffer.size - self.__buffer.putdata([color] * (width * height)) - return self - - def draw(self) -> ImageDraw: - """Returns a PIL ImageDraw instance for 2D drawing on the image buffer.""" - return ImageDraw.Draw(self.__buffer) - - def is_inverted(self) -> bool: - """Returns the current inversion state.""" - return self.__inverted - - def invert(self, state: bool = True): - """Sets display inversion to the specified state. If not provided, state - is True, which inverts the display. If state is False, the display turns - back into normal mode.""" - if state: - self.command(CMD_INVON) - else: - self.command(CMD_INVOFF) - self.__inverted = state - return self - - def is_idle(self) -> bool: - """Returns the current idle state.""" - return self.__idle - - def idle(self, state: bool = True): - """Sets the display idle state to the specified state. If not provided, - state is True, which turns the idle mode on. If state is False, the - display turns back into normal mode. In idle mode colors expression is - reduced.""" - if state: - self.command(CMD_IDLON) - else: - self.command(CMD_IDLOFF) - self.__idle = state - return self - - def on(self): - """Turns the display on.""" - return self.command(CMD_DISPON) - - def off(self): - """Turns the display off.""" - return self.command(CMD_DISPOFF) - - def sleep(self): - """Turns the displays sleep mode on""" - self.command(CMD_SLPIN) - time.sleep(0.005) - return self - - def wake_up(self): - """Turns the displays sleep mode off""" - self.command(CMD_SLPOUT) - time.sleep(0.005) - return self diff --git a/README.md b/README.md index b80e54a..e6ec0ce 100644 --- a/README.md +++ b/README.md @@ -4,32 +4,55 @@ Python ILI9486 Display Driver Python module to control an ILI9486 LCD. Based upon the deprecated Python ILI9341 from [Adafruit](https://github.com/adafruit/Adafruit_Python_ILI9341) and the adapted version for ILI9486 from [Liqun Hu](https://github.com/huliqun/Myway_Python_ILI9486). -Rewritten to use `spidev` and `rpi-lgpio` instead of the discontinued Adafruit counterpart libraries. +Rewritten to use `spidev` and either `gpiod`, `lgpio` or `rpi-gpio` instead of the discontinued Adafruit counterpart +libraries. +## Supported displays +* [MPI3501](https://www.lcdwiki.com/3.5inch_RPi_Display) +* [MHS3528](https://www.lcdwiki.com/MHS-3.5inch_RPi_Display) ## Installation and use Call `sudo raspi-config` and then select `Interface Options > SPI` to enable SPI. -Install system dependencies: -````bash -sudo apt install libopenjp2-7 libopenblas0 python3 python3-rpi-lgpio -```` +```py +# config (should be the same for basically all displays you can buy) +DC_PIN = 24 +RS_PIN = 25 +SPI_BUS = 0 +SPI_DEVICE = 0 -Create a virtual environment: -````bash -python -m venv .venv -```` +# create SPI device +from spidev import SpiDev +spi = SpiDev(SPI_BUS, SPI_DEVICE) +spi.mode = 0b10 # [CPOL|CPHA] -> polarity 1, phase 0 +spi.max_speed_hz = 64000000 -Install python dependencies: -````bash -.venv/bin/pip install -r requirements.txt -```` +# create GPIO facade (choose the one you already use in your project, or pick `gpiod` or `lgpio`) +from pyili9486.gpio.lgpio_facade import LGPIOFacade +gpio_facade = LGPIOFacade(DC_PIN, RS_PIN) -Run example: -````bash -.venv/bin/python image.py -```` +# create the LCD instance +from pyili9486 import ILI9486 +lcd = ILI9486(spi, gpio_facade) + +# draw some stuff +from PIL import Image + +width, height = lcd.dimensions + +red = Image.new(mode='RGB', size=(width // 2, height // 2), color=(255, 0, 0)) +green = Image.new(mode='RGB', size=(width // 2, height // 2), color=(0, 255, 0)) +blue = Image.new(mode='RGB', size=(width // 2, height // 2), color=(0, 0, 255)) +white = Image.new(mode='RGB', size=(width // 2, height // 2), color=(255, 255, 255)) + +lcd.begin() + +lcd.display(red, 0, 0) +lcd.display(green, width // 2, 0) +lcd.display(blue, 0, height // 2) +lcd.display(white, width // 2, height // 2) +``` ## Notes @@ -43,8 +66,9 @@ black pixels. Adafruit invests time and resources providing this open source code, please support Adafruit and open-source hardware by purchasing products from Adafruit! -Written by Tony DiCola for Adafruit Industries. -Adapted for ILI9486 by Liqun Hu. -Modified and maintained by Thorben Yzer. +Written by Tony DiCola for Adafruit Industries.
+Adapted for ILI9486 by Liqun Hu.
+Modified and maintained by Thorben Yzer.
+Support for MHS3528 variant by Craig Lamparter and hemna.
MIT license, all text above must be included in any redistribution diff --git a/config.py b/config.py deleted file mode 100644 index 08e1a3b..0000000 --- a/config.py +++ /dev/null @@ -1,6 +0,0 @@ -# Pin definition -RST_PIN = 25 -DC_PIN = 24 -# SPI definition -SPI_BUS = 0 -SPI_DEVICE = 0 diff --git a/image.py b/image.py deleted file mode 100644 index 5ccd789..0000000 --- a/image.py +++ /dev/null @@ -1,68 +0,0 @@ -from PIL import Image -import RPi.GPIO as GPIO -from spidev import SpiDev -import time -import ILI9486 as LCD -import config - -spi: SpiDev = None - -if __name__ == '__main__': - try: - GPIO.setmode(GPIO.BCM) - spi = SpiDev(config.SPI_BUS, config.SPI_DEVICE) - spi.mode = 0b10 # [CPOL|CPHA] -> polarity 1, phase 0 - # default value - # spi.lsbfirst = False # set to MSB_FIRST / most significant bit first - spi.max_speed_hz = 64000000 - lcd = LCD.ILI9486(dc=config.DC_PIN, rst=config.RST_PIN, spi=spi).begin() - print(f'Initialized display with landscape mode = {lcd.is_landscape()} and dimensions {lcd.dimensions()}') - print('Loading image...') - image = Image.open('sample.png') - width, height = image.size - partial = image.resize((width // 2, height // 2)) - - while True: - print('Drawing image') - lcd.display(image) - time.sleep(1) - print('Drawing partial image') - lcd.display(partial) - time.sleep(1) - print('Turning on inverted mode') - lcd.invert() - time.sleep(1) - print('Turning off inverted mode') - lcd.invert(False) - time.sleep(1) - print('Turning off display') - lcd.off() - time.sleep(1) - print('Turning on display') - lcd.on() - time.sleep(1) - print('Turning on idle mode') - lcd.idle() - time.sleep(1) - print('Turning off idle mode') - lcd.idle(False) - time.sleep(1) - print('Clearing display') - lcd.clear().display() - time.sleep(1) - print('Resetting display') - lcd.begin() - time.sleep(1) - print('Turning on sleep mode') - lcd.sleep() - time.sleep(1) - print('Turning off sleep mode') - lcd.wake_up() - time.sleep(1) - - except KeyboardInterrupt: - # catching keyboard interrupt to exit, but do the cleanup in finally block - pass - finally: - GPIO.cleanup() - spi.close() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1c71226 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "pyILI9486" +version = "1.0.0" +description = "display library for ILI9486 based SPI displays" +authors = [ + { name = "Tony Di Cola (tdicola)" }, + { name = "Liqun Hu (huliqun)" }, + { name = "Thorben Yzer (SirLefti)", email = "62506842+SirLefti@users.noreply.github.com" }, + { name = "Craig Lamparter (craigerl)" }, + { name = "hemna" } +] +readme = "README.md" +requires-python = ">=3.11" +license = { file = "LICENSE" } + +dependencies = [ + "Pillow>=12.0.0", + "spidev>=3.8; sys_platform == 'linux'", + "numpy>=2.4.0" +] + +[project.optional-dependencies] +gpiod = ["gpiod>=2.4.0"] +rpilgpio = ["rpi-lgpio>=0.6"] +lgpio = ["lgpio>=0.2.2.0"] +build = ["uv_build"] +dev = ["isort", "pytest"] + +[project.urls] +Homepage = "https://github.com/SirLefti/Python_ILI9486" +Source = "https://github.com/SirLefti/Python_ILI9486" + +[build-system] +requires = ["uv_build>=0.11.12,<0.12"] +build-backend = "uv_build" + +[tool.pytest] +pythonpath = ["src"] +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index c4e334a..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -Pillow -rpi-lgpio -spidev -numpy - diff --git a/sample.png b/sample.png deleted file mode 100644 index 8bf9eeb..0000000 Binary files a/sample.png and /dev/null differ diff --git a/src/pyili9486/__init__.py b/src/pyili9486/__init__.py new file mode 100644 index 0000000..380a448 --- /dev/null +++ b/src/pyili9486/__init__.py @@ -0,0 +1,468 @@ +# Copyright (c) +# Authors: Tony DiCola (tdicola), Liqun Hu (huliqun), Thorben Yzer (SirLefti), +# Craig Lamparter (craigerl), hemna +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +import time +from enum import Enum, IntEnum +from typing import TYPE_CHECKING + +import numpy as np +from PIL import Image, ImageDraw + +from pyili9486.gpio import Pin + +if TYPE_CHECKING: + from spidev import SpiDev + + from pyili9486.gpio import GPIOFacade +else: + SpiDev = object + GPIOFacade = object + +# commands +CMD_RDPXLFMT = 0x0C + +CMD_SLPIN = 0x10 +CMD_SLPOUT = 0x11 + +CMD_INVOFF = 0x20 +CMD_INVON = 0x21 +CMD_DISPOFF = 0x28 +CMD_DISPON = 0x29 + +CMD_SETCA = 0x2A +CMD_SETPA = 0x2B +CMD_WRMEM = 0x2C +CMD_RDMEM = 0x2E + +CMD_MACCTL = 0x36 +CMD_IDLOFF = 0x38 +CMD_IDLON = 0x39 +CMD_PXLFMT = 0x3A + +CMD_IFMODE = 0xB0 +CMD_DINVCTL = 0xB4 + +CMD_PWRCTL2 = 0xC1 +CMD_PWRCTLNOR = 0xC2 +CMD_VCOMCTL = 0xC5 + +CMD_PGAMCTL = 0xE0 +CMD_NGAMCTL = 0xE1 + + +# defaults +P_GAM_DEFAULT = [0x0F, 0x1F, 0x1C, 0x0C, 0x0F, 0x08, 0x48, 0x98, 0x37, 0x0A, 0x13, 0x04, 0x11, 0x0D, 0x00] +N_GAM_DEFAULT = [0x0F, 0x32, 0x2E, 0x0B, 0x0D, 0x05, 0x47, 0x75, 0x37, 0x06, 0x10, 0x03, 0x24, 0x20, 0x00] + + +class SKU(Enum): + """ + Representation of the supported display SKUs. Check the linked wiki pages to see which one is yours. + """ + MPI3501 = 0 + """Datasheet: https://www.lcdwiki.com/3.5inch_RPi_Display""" + MHS3528 = 1 + """Datasheet: https://www.lcdwiki.com/MHS-3.5inch_RPi_Display""" + + +class PixelFormat(IntEnum): + """ + Representation of the supported display pixel formats. Currently, SKU MPI3501 is hardcoded to use RGB666, while + MHS3528 is hardcoded to use RGB565. + """ + RGB565 = 0x55 + RGB666 = 0x66 + + @classmethod + def from_sku(cls, sku: SKU): + match sku: + case SKU.MPI3501: + return cls.RGB666 + case SKU.MHS3528: + return cls.RGB565 + + +class Origin(IntEnum): + """ + Representation of the display origin. The origin is defined by the position of the image relative to the default + orientation of the raspberry. The default orientation has the GPIO pins being on top, so that the raspberry logo + and the model's name are readable. The reference point is then the upper left corner, where GPIO pin 1 is located. + The origin UPPER_LEFT is the default origin, because the reference point is in the upper left corner. + """ + + # data: MY | MX | MV | ML | BGR | MH | X | X + # The MV bit controls the format. 0 means portrait mode, 1 means landscape mode. + # The BGR bit is a bit weird. 0 means RGB mode, 1 means BGR mode. However, we always set it to 1 / BGR, despite + # using RGB pixel format. Maybe the documentation is wrong here. + UPPER_LEFT = 0x28 + UPPER_LEFT_MIRRORED = 0xA8 + LOWER_LEFT = 0x48 + LOWER_LEFT_MIRRORED = 0x08 + UPPER_RIGHT = 0x88 + UPPER_RIGHT_MIRRORED = 0xC8 + LOWER_RIGHT = 0xE8 + LOWER_RIGHT_MIRRORED = 0x68 + + +def image_to_data(image: Image.Image, pixel_format: PixelFormat) -> list[int]: + """ + Converts a PIL image to RGB666 or RGB565 format that can be drawn on the LCD. + :param image: PIL image to convert + :param pixel_format: target pixel format + :return: byte stream of converted data + """ + match pixel_format: + case PixelFormat.RGB565: + pb = np.array(image.convert('RGB')).astype(np.uint16) + + r = (pb[..., 0] >> 3) & 0x1F # 5 bits + g = (pb[..., 1] >> 2) & 0x3F # 6 bits + b = (pb[..., 2] >> 3) & 0x1F # 5 bits + + rgb565 = (r << 11) | (g << 5) | b + + return rgb565.astype(np.uint16).byteswap().view(np.uint8).flatten().tolist() + + case PixelFormat.RGB666: + pb = np.array(image.convert('RGB')).astype(np.uint16) + return (pb & 0xFC).astype(np.uint8).flatten().tolist() + + +class ILI9486: + """Representation of an ILI9486 TFT.""" + + __LCD_WIDTH = 320 + __LCD_HEIGHT = 480 + + def __init__(self, spi: SpiDev, gpio_facade: GPIOFacade, *, origin: Origin = Origin.UPPER_LEFT, + sku: SKU = SKU.MPI3501): + """ + Creates a new ILI9486 TFT instance. + :param spi: SpiDev connection to use + :param gpio_facade: GPIO back-end to use + :param origin: origin to use + :param sku: SKU of the display + """ + self.__spi = spi + self.__gpio = gpio_facade + self.__origin = origin + self.__sku = sku + + self.__width = self.__LCD_WIDTH + self.__height = self.__LCD_HEIGHT + self.__inverted = False + self.__idle = False + + # swap width and height if selected origin is landscape mode by checking if third bit is 1 + if self.is_landscape: + self.__width, self.__height = self.__height, self.__width + self.__buffer = Image.new('RGB', (self.__width, self.__height), (0, 0, 0)) + + @property + def landscape_dimensions(self) -> tuple[int, int]: + """ + Returns the display dimensions in landscape mode, no matter what mode is used. + :return: dimension tuple in landscape mode + """ + return self.__LCD_HEIGHT, self.__LCD_WIDTH + + @property + def portrait_dimensions(self) -> tuple[int, int]: + """ + Returns the display dimensions in portrait mode, no matter what mode is used. + :return: dimension tuple in portrait mode + """ + return self.__LCD_WIDTH, self.__LCD_HEIGHT + + @property + def dimensions(self) -> tuple[int, int]: + """ + Returns the current display dimensions. + :return: dimension tuple [width, height] + """ + return self.__width, self.__height + + @property + def is_landscape(self) -> bool: + """ + Returns whether the display is in landscape mode. + :return: `true` if selected origin is landscape mode; `false` otherwise + """ + return bool(self.__origin.value & 0x20) + + def __mpi3501_init(self): + self.command(CMD_IFMODE).data(0x00) + self.command(CMD_SLPOUT) # turns off the sleep mode + time.sleep(0.020) + + self.command(CMD_PXLFMT).data(PixelFormat.from_sku(SKU.MPI3501)) + self.command(CMD_RDPXLFMT).data(PixelFormat.from_sku(SKU.MPI3501)) + + self.command(CMD_PWRCTLNOR).command(0x44) + + self.command(CMD_VCOMCTL).send([0x00, 0x00, 0x00, 0x00], True, chunk_size=1) + self.command(CMD_PGAMCTL).send(P_GAM_DEFAULT, True, chunk_size=1) + self.command(CMD_NGAMCTL).send(N_GAM_DEFAULT, True, chunk_size=1) + + self.command(CMD_MACCTL).data(self.__origin.value) # memory address control + + self.command(CMD_SLPOUT) + self.command(CMD_DISPON) + + def __mhs3528_init(self): + # Manufacturer-specific registers + self.command(0xF1).data([0x36, 0x04, 0x00, 0x3C, 0x0F, 0x8F]) + self.command(0xF2).data([0x18, 0xA3, 0x12, 0x02, 0xB2, 0x12, 0xFF, 0x10, 0x00]) + self.command(0xF8).data([0x21, 0x04]) + self.command(0xF9).data([0x00, 0x08]) + + self.command(CMD_MACCTL).data(0x08) # memory address control - initial + + self.command(CMD_DINVCTL).data(0x00) + + self.command(CMD_PWRCTL2).data(0x41) + + self.command(CMD_VCOMCTL).data([0x00, 0x91, 0x80, 0x00]) + self.command(CMD_PGAMCTL).data(P_GAM_DEFAULT) + self.command(CMD_NGAMCTL).data(N_GAM_DEFAULT) + + self.command(CMD_PXLFMT).data(PixelFormat.from_sku(SKU.MHS3528)) + self.command(CMD_SLPOUT) + self.command(CMD_MACCTL).data(self.__origin.value) # memory address control - final origin + + # Delay 255ms + time.sleep(0.255) + + # Display On + self.command(CMD_DISPON) + + def send(self, data: int | list[int], is_data: bool = True, chunk_size: int = 4096): + """ + Writes a byte or an array of bytes to the display. + :param data: data as int or list of int + :param is_data: set to `true` to send data as data; set to `false` to send data as command + :param chunk_size: + :return: Self + """ + # dc low for command, high for data + with self.__gpio.set_values({Pin.DC: is_data}): + if isinstance(data, int): + self.__spi.writebytes([data]) + else: + for start in range(0, len(data), chunk_size): + end = min(start + chunk_size, len(data)) + self.__spi.writebytes(data[start: end]) + return self + + def command(self, data: int): + """ + Writes a byte to the display as a command. + :param data: data as int + :return: Self + """ + return self.send(data, False) + + def data(self, data: int | list[int]): + """ + Writes a byte or an array of bytes to the display as data. + :param data: data as int or list of int + :return: Self + """ + return self.send(data, True) + + def reset(self): + """ + Resets the display if a reset pin is available. + :return: Self + """ + with self.__gpio.set_values({Pin.RS: True}) as context: + context.set_value(Pin.RS, True) + time.sleep(.001) # wait a bit to make sure the output was HIGH + context.set_value(Pin.RS, False) + time.sleep(.000100) # wait 100 µs to trigger the reset (should be 10 µs, but the OS is not precise enough) + context.set_value(Pin.RS, True) + time.sleep(.120) # wait 120 ms for finishing blanking and resetting + self.__inverted = False + self.__idle = False + return self + + def _init_sequence(self): + """ + Initializes the display + :return: Self + """ + match self.__sku: + case SKU.MPI3501: + self.__mpi3501_init() + case SKU.MHS3528: + self.__mhs3528_init() + return self + + def begin(self): + """ + Initializes the display by resetting it and calling the init sequence. + :return: Self + """ + return self.reset()._init_sequence() + + def set_window(self, x0: int = 0, y0: int = 0, x1: int | None = None, y1: int | None = None): + """ + Sets the pixel address window for proceeding drawing commands. Leave all coordinates empty for full screen + window. Coordinates must be in range `[0, width-1]` and `[0, height-1]` respectively. Start coordinates cannot + be greater than end coordinates. + :param x0: start x coordinate + :param y0: start y coordinate + :param x1: end x coordinate + :param y1: end y coordinate + :return: Self + """ + if x1 is None: + x1 = self.__width - 1 + if y1 is None: + y1 = self.__height - 1 + self.command(CMD_SETCA) # column address + self.data(x0 >> 8) + self.data(x0 & 0xFF) + self.data(x1 >> 8) + self.data(x1 & 0xFF) + self.command(CMD_SETPA) # page address / row address + self.data(y0 >> 8) + self.data(y0 & 0xFF) + self.data(y1 >> 8) + self.data(y1 & 0xFF) + return self + + def display(self, image: Image.Image | None = None, x0: int = 0, y0: int = 0): + """ + Writes the display buffer or provided image to the display. If no image is provided, the display buffer will be + written to the display. A provided image cannot exceed the display bounds. + :param image: image to display, or `None` to display the internal buffer + :param x0: x coordinate + :param y0: y coordinate + :return: Self + """ + if image is None: + image = self.__buffer + width, height = image.size + x1 = x0 + width - 1 + y1 = y0 + height - 1 + if image.mode != 'RGB': + raise ValueError('Image must be in RGB format') + if x1 >= self.__width or y1 >= self.__height or x0 < 0 or y0 < 0: + raise ValueError('Image exceeds display bounds ({0}x{1})'.format(self.__width, self.__height)) + self.set_window(x0, y0, x1, y1) + data = image_to_data(image, PixelFormat.from_sku(self.__sku)) + self.command(CMD_WRMEM) + self.data(data) + return self + + def clear(self, color: tuple[int, int, int] = (0, 0, 0)): + """ + Clears the image buffer to the specified RGB color or black if not provided. + :param color: default color to override the buffer + :return: Self + """ + width, height = self.__buffer.size + self.__buffer.putdata([color] * (width * height)) + return self + + def draw(self) -> ImageDraw.ImageDraw: + """ + Returns a PIL ImageDraw instance for 2D drawing on the image buffer. + :return: PIL ImageDraw instance of the buffer + """ + return ImageDraw.Draw(self.__buffer) + + @property + def is_inverted(self) -> bool: + """ + Returns the current inversion state. + :return: `true` if inverted; `false` otherwise + """ + return self.__inverted + + def invert(self, state: bool = True): + """ + Sets display inversion to the specified state. If not provided, state is True, which inverts the display. + If state is False, the display turns back into normal mode. + :param state: set to `true` to invert the display; set to `false` to turn back to normal mode + :return: Self + """ + if state: + self.command(CMD_INVON) + else: + self.command(CMD_INVOFF) + self.__inverted = state + return self + + @property + def is_idle(self) -> bool: + """ + Returns the current idle state. + :return: `true` if idle; `false` otherwise + """ + return self.__idle + + def idle(self, state: bool = True): + """ + Sets the display idle state to the specified state. If not provided, state is True, which turns the idle mode + on. If state is False, the display turns back into normal mode. In idle mode colors expression is reduced. + :param state: set to `true` to enable idle mode; set to `false` to disable idle mode + :return: Self + """ + if state: + self.command(CMD_IDLON) + else: + self.command(CMD_IDLOFF) + self.__idle = state + return self + + def on(self): + """ + Turns the display on. + :return: Self + """ + return self.command(CMD_DISPON) + + def off(self): + """ + Turns the display off. + :return: Self + """ + return self.command(CMD_DISPOFF) + + def sleep(self): + """ + Turns the displays sleep mode on. + :return: Self + """ + self.command(CMD_SLPIN) + time.sleep(0.005) + return self + + def wake_up(self): + """ + Turns the displays sleep mode off. + :return: Self + """ + self.command(CMD_SLPOUT) + time.sleep(0.005) + return self diff --git a/src/pyili9486/gpio/__init__.py b/src/pyili9486/gpio/__init__.py new file mode 100644 index 0000000..635e3cc --- /dev/null +++ b/src/pyili9486/gpio/__init__.py @@ -0,0 +1,44 @@ +from abc import ABC, abstractmethod +from enum import IntEnum +from typing import Self + + +class Pin(IntEnum): + DC = 0 # data/command + RS = 1 # reset + + +PinConfig = dict[Pin, bool] +PinMap = dict[Pin, int | None] + + +class GPIOContext(ABC): + + def __init__(self, pin_map: PinMap, config: PinConfig): + self._pin_map = pin_map + self._config = config + + @abstractmethod + def __enter__(self) -> Self: + raise NotImplementedError + + @abstractmethod + def __exit__(self, exc_type, exc_val, exc_tb): + raise NotImplementedError + + @abstractmethod + def set_value(self, pin: Pin, value: bool): + raise NotImplementedError + +class GPIOFacade(ABC): + + def __init__(self, dc_pin: int, rs_pin: int | None = None): + self._pin_map: PinMap = { + Pin.DC: dc_pin, + Pin.RS: rs_pin + } + + @abstractmethod + def set_values(self, config: PinConfig) -> GPIOContext: + raise NotImplementedError + diff --git a/src/pyili9486/gpio/gpiod_facade.py b/src/pyili9486/gpio/gpiod_facade.py new file mode 100644 index 0000000..15e16d0 --- /dev/null +++ b/src/pyili9486/gpio/gpiod_facade.py @@ -0,0 +1,51 @@ +import gpiod +from gpiod.line import Direction, Value + +from pyili9486.gpio import GPIOContext, GPIOFacade, Pin, PinConfig, PinMap + + +class _GPIODContext(GPIOContext): + + def __init__(self, gpio_chip_path: str, pin_map: PinMap, config: PinConfig): + super().__init__(pin_map, config) + + self._gpio_chip_path = gpio_chip_path + self._request = None + + def __enter__(self): + config = { + self._pin_map[pin]: gpiod.LineSettings( + direction=Direction.OUTPUT, + output_value=Value.ACTIVE if value else Value.INACTIVE + ) + for pin, value in self._config.items() if self._pin_map[pin] is not None + } + + self._request = gpiod.request_lines( + self._gpio_chip_path, + consumer="gpiod-consumer", + config=config + ) + return self + + def set_value(self, pin: Pin, value: bool): + if self._request is not None: + pin_id = self._pin_map[pin] + if pin_id is not None: + self._request.set_value(pin_id, Value.ACTIVE if value else Value.INACTIVE) + else: + raise ValueError('must be called inside context manager') + + def __exit__(self, exc_type, exc_val, exc_tb): + if self._request: + self._request.release() + self._request = None + + +class GPIODFacade(GPIOFacade): + def __init__(self, dc_pin: int, rs_pin: int | None = None, gpio_chip_id: int = 0): + super().__init__(dc_pin, rs_pin) + self._gpiod_chip_path = f"/dev/gpiochip{gpio_chip_id}" + + def set_values(self, config: PinConfig) -> GPIOContext: + return _GPIODContext(self._gpiod_chip_path, self._pin_map, config) diff --git a/src/pyili9486/gpio/lgpio_facade.py b/src/pyili9486/gpio/lgpio_facade.py new file mode 100644 index 0000000..c2cc894 --- /dev/null +++ b/src/pyili9486/gpio/lgpio_facade.py @@ -0,0 +1,40 @@ +from typing import Self + +import lgpio + +from pyili9486.gpio import GPIOContext, GPIOFacade, Pin, PinConfig, PinMap + + +class _LGPIOContext(GPIOContext): + + def __init__(self, gpio, pin_map: PinMap, config: PinConfig): + super().__init__(pin_map, config) + self._gpio = gpio + + def __enter__(self) -> Self: + for pin, value in self._config.items(): + pin_id = self._pin_map[pin] + if pin_id is not None: + lgpio.gpio_claim_output(self._gpio, pin_id, value) + return self + + def set_value(self, pin: Pin, value: bool): + pin_id = self._pin_map[pin] + if pin_id is not None: + lgpio.gpio_write(self._gpio, pin_id, value) + + def __exit__(self, exc_type, exc_val, exc_tb): + for pin, value in self._config.items(): + pin_id = self._pin_map[pin] + if pin_id is not None: + lgpio.gpio_free(self._gpio, pin_id) + + +class LGPIOFacade(GPIOFacade): + def __init__(self, dc_pin: int, rs_pin: int | None = None, gpio_chip_id: int = 0): + super().__init__(dc_pin, rs_pin) + + self._gpio = lgpio.gpiochip_open(gpio_chip_id) + + def set_values(self, config: PinConfig) -> GPIOContext: + return _LGPIOContext(self._gpio, self._pin_map, config) diff --git a/src/pyili9486/gpio/rpilgpio_facade.py b/src/pyili9486/gpio/rpilgpio_facade.py new file mode 100644 index 0000000..e4e6285 --- /dev/null +++ b/src/pyili9486/gpio/rpilgpio_facade.py @@ -0,0 +1,42 @@ +from typing import Self + +import RPi.GPIO as GPIO + +from pyili9486.gpio import GPIOContext, GPIOFacade, Pin, PinConfig, PinMap + + +class _RPiLGPIOContext(GPIOContext): + + def __init__(self, pin_map: PinMap, config: PinConfig): + super().__init__(pin_map, config) + + def __enter__(self) -> Self: + for pin, value in self._config.items(): + pin_id = self._pin_map[pin] + if pin_id is not None: + GPIO.output(pin_id, value) + return self + + def set_value(self, pin: Pin, value: bool): + pin_id = self._pin_map[pin] + if pin_id is not None: + GPIO.output(pin_id, value) + + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + +class RPiLGPIOFacade(GPIOFacade): + def __init__(self, dc_pin: int, rs_pin: int | None = None): + super().__init__(dc_pin, rs_pin) + + GPIO.setmode(GPIO.BCM) + GPIO.setup(self._pin_map[Pin.DC], GPIO.OUT) + GPIO.output(self._pin_map[Pin.DC], GPIO.HIGH) + if self._pin_map[Pin.RS] is not None: + GPIO.setup(self._pin_map[Pin.RS], GPIO.OUT) + GPIO.output(self._pin_map[Pin.RS], GPIO.HIGH) + + + def set_values(self, config: PinConfig) -> GPIOContext: + return _RPiLGPIOContext(self._pin_map, config) diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..ce36d6d --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,78 @@ +import colorsys +import time +from typing import Type + +import numpy as np +import pytest +from PIL import Image +from spidev import SpiDev + +from pyili9486 import ILI9486 +from pyili9486.gpio import GPIOFacade +from pyili9486.gpio.gpiod_facade import GPIODFacade +from pyili9486.gpio.lgpio_facade import LGPIOFacade +from pyili9486.gpio.rpilgpio_facade import RPiLGPIOFacade + + +@pytest.fixture(params=[ + GPIODFacade, LGPIOFacade, RPiLGPIOFacade +]) +def lcd(request): + spi = SpiDev(0, 0) + spi.mode = 0b10 # [CPOL|CPHA] -> polarity 1, phase 0 + spi.max_speed_hz = 64000000 + gpio_class: Type[GPIOFacade] = request.param + gpio = gpio_class(24, 25) + + yield ILI9486(spi, gpio) + + spi.close() + + +def generate_gradient(width: int, height: int) -> Image.Image: + h = np.linspace(0, 1, width) + s = np.ones(height) + v = np.ones(height) + + fraction = int(height // 2) + + s[:fraction] = np.linspace(0, 1, fraction) + v[-fraction:] = np.linspace(1, 0, fraction) + + H, S = np.meshgrid(h, s) + _, V = np.meshgrid(h, v) + + r, g, b = np.vectorize(colorsys.hsv_to_rgb)(H, S, V) + rgb = (np.dstack((r, g, b)) * 255).astype(np.uint8) + + image = Image.fromarray(rgb, "RGB") + return image + + +def test_facade(lcd: ILI9486): + width, height = lcd.dimensions + + red = Image.new(mode='RGB', size=(width // 2, height // 2), color=(255, 0, 0)) + green = Image.new(mode='RGB', size=(width // 2, height // 2), color=(0, 255, 0)) + blue = Image.new(mode='RGB', size=(width // 2, height // 2), color=(0, 0, 255)) + white = Image.new(mode='RGB', size=(width // 2, height // 2), color=(255, 255, 255)) + + gradient = generate_gradient(width, height) + + lcd.begin() + + lcd.display(red, 0, 0) + lcd.display(green, width // 2, 0) + lcd.display(blue, 0, height // 2) + lcd.display(white, width // 2, height // 2) + time.sleep(1) + + lcd.invert() + time.sleep(1) + + lcd.invert(False) + lcd.display(gradient) + time.sleep(1) + + lcd.clear() + lcd.reset()