From 6a4ae78c3b61f881f27eedec7dc9c86254cf7ef5 Mon Sep 17 00:00:00 2001 From: SirLefti <62506842+SirLefti@users.noreply.github.com> Date: Mon, 11 May 2026 11:56:23 +0200 Subject: [PATCH 01/11] typing and fixes --- ILI9486.py | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/ILI9486.py b/ILI9486.py index d13b8eb..2d1731c 100644 --- a/ILI9486.py +++ b/ILI9486.py @@ -18,7 +18,7 @@ # 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 +from enum import IntEnum import time import numpy as np from PIL import Image, ImageDraw @@ -59,14 +59,14 @@ CMD_NGAMCTL = 0xE1 -def image_to_data(image: Image) -> object: +def image_to_data(image: Image.Image) -> list: """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): +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. @@ -133,7 +133,7 @@ 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): + def send(self, data: int | list, 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) @@ -145,11 +145,11 @@ def send(self, data, is_data=True, chunk_size=4096): self.__spi.writebytes(data[start: end]) return self - def command(self, data): + def command(self, data: int): """Writes a byte or an array of bytes to the display as a command.""" return self.send(data, False) - def data(self, data): + def data(self, data: int | list): """Writes a byte or an array of bytes to the display as data.""" return self.send(data, True) @@ -172,8 +172,8 @@ def _init_sequence(self): 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_PXLFMT).data(0x66) + self.command(CMD_RDPXLFMT).data(0x66) self.command(CMD_PWRCTLNOR).command(0x44) @@ -197,7 +197,7 @@ 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): + 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.""" if x1 is None: x1 = self.__width - 1 @@ -215,7 +215,7 @@ def set_window(self, x0=0, y0=0, x1=None, y1=None): self.data(y1 & 0xFF) return self - def display(self, image=None, x0 = 0, y0 = 0): + 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. If an image is provided, it should be in RGB format and the same @@ -228,13 +228,11 @@ def display(self, image=None, x0 = 0, y0 = 0): 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)) + 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)) + self.data(data) return self def clear(self, color=(0, 0, 0)): @@ -243,7 +241,7 @@ def clear(self, color=(0, 0, 0)): self.__buffer.putdata([color] * (width * height)) return self - def draw(self) -> ImageDraw: + def draw(self) -> ImageDraw.ImageDraw: """Returns a PIL ImageDraw instance for 2D drawing on the image buffer.""" return ImageDraw.Draw(self.__buffer) From 6535c77a6a1c1ee6d6ecf56686d7a19fd50dde12 Mon Sep 17 00:00:00 2001 From: SirLefti <62506842+SirLefti@users.noreply.github.com> Date: Mon, 11 May 2026 14:55:26 +0200 Subject: [PATCH 02/11] code beauty --- ILI9486.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/ILI9486.py b/ILI9486.py index 2d1731c..6d0d691 100644 --- a/ILI9486.py +++ b/ILI9486.py @@ -25,9 +25,6 @@ import RPi.GPIO as GPIO from spidev import SpiDev -# constants -LCD_WIDTH = 320 -LCD_HEIGHT = 480 # commands CMD_RDPXLFMT = 0x0C @@ -89,15 +86,8 @@ class Origin(IntEnum): 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 + __LCD_WIDTH = 320 + __LCD_HEIGHT = 480 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 @@ -108,8 +98,9 @@ def __init__(self, spi: SpiDev, dc: int, rst: int = None, *, origin: Origin = Or self.__dc = dc self.__rst = rst self.__origin = origin - self.__width = LCD_WIDTH - self.__height = LCD_HEIGHT + + self.__width = self.__LCD_WIDTH + self.__height = self.__LCD_HEIGHT self.__inverted = False self.__idle = False @@ -121,14 +112,26 @@ def __init__(self, spi: SpiDev, dc: int, rst: int = None, *, origin: Origin = Or 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: + 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: + """Returns the display dimensions in landscape mode, no matter what mode is used""" + return self.__LCD_HEIGHT, self.__LCD_WIDTH + + @property + def portrait_dimensions(self) -> tuple: + """Returns the display dimensions in portrait mode, no matter what mode is used""" + return self.__LCD_WIDTH, self.__LCD_HEIGHT + + @property def dimensions(self) -> tuple: """Returns the current display dimensions""" return self.__width, self.__height + @property def is_landscape(self) -> bool: """Returns true if selected origin is landscape mode; false otherwise""" return bool(self.__origin.value & 0x20) @@ -245,6 +248,7 @@ def draw(self) -> ImageDraw.ImageDraw: """Returns a PIL ImageDraw instance for 2D drawing on the image buffer.""" return ImageDraw.Draw(self.__buffer) + @property def is_inverted(self) -> bool: """Returns the current inversion state.""" return self.__inverted @@ -260,6 +264,7 @@ def invert(self, state: bool = True): self.__inverted = state return self + @property def is_idle(self) -> bool: """Returns the current idle state.""" return self.__idle From b303d41f5ff56e848600f3fdffa2edd3d0d2abd2 Mon Sep 17 00:00:00 2001 From: SirLefti <62506842+SirLefti@users.noreply.github.com> Date: Mon, 11 May 2026 15:05:36 +0200 Subject: [PATCH 03/11] pure colors in test script --- image.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/image.py b/image.py index 5ccd789..81c2375 100644 --- a/image.py +++ b/image.py @@ -16,18 +16,21 @@ # 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(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)) + 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)) while True: print('Drawing image') - lcd.display(image) - time.sleep(1) - print('Drawing partial image') - lcd.display(partial) + lcd.display(red) + lcd.display(green, x0 = width // 2) + lcd.display(blue, y0 = height // 2) + lcd.display(partial, x0 = width // 2, y0 = height // 2) time.sleep(1) print('Turning on inverted mode') lcd.invert() From 99bf603cb65f103dfac8c0ff2bb699fffa25a2fc Mon Sep 17 00:00:00 2001 From: SirLefti <62506842+SirLefti@users.noreply.github.com> Date: Mon, 11 May 2026 16:48:29 +0200 Subject: [PATCH 04/11] quick support for MHS3528 specific init sequences --- ILI9486.py | 109 +++++++++++++++++++++++++++++++++++++---------------- README.md | 10 +++-- image.py | 8 ++-- 3 files changed, 88 insertions(+), 39 deletions(-) diff --git a/ILI9486.py b/ILI9486.py index 6d0d691..f9c029b 100644 --- a/ILI9486.py +++ b/ILI9486.py @@ -1,5 +1,6 @@ # Copyright (c) -# Authors: Tony DiCola, Liqun Hu, Thorben Yzer +# 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 @@ -18,14 +19,14 @@ # 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 IntEnum import time +from enum import Enum, IntEnum + import numpy as np -from PIL import Image, ImageDraw import RPi.GPIO as GPIO +from PIL import Image, ImageDraw from spidev import SpiDev - # commands CMD_RDPXLFMT = 0x0C @@ -48,7 +49,9 @@ CMD_PXLFMT = 0x3A CMD_IFMODE = 0xB0 +CMD_DINVCTL = 0xB4 +CMD_PWRCTL2 = 0xC1 CMD_PWRCTLNOR = 0xC2 CMD_VCOMCTL = 0xC5 @@ -56,11 +59,15 @@ CMD_NGAMCTL = 0xE1 -def image_to_data(image: Image.Image) -> list: - """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() +# 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): + MPI3501 = 0 + """Datasheet: https://www.lcdwiki.com/3.5inch_RPi_Display""" + MHS3528 = 1 + """Datasheet: https://www.lcdwiki.com/MHS-3.5inch_RPi_Display""" class Origin(IntEnum): @@ -83,13 +90,20 @@ class Origin(IntEnum): LOWER_RIGHT_MIRRORED = 0x68 +def image_to_data(image: Image.Image) -> list: + """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 ILI9486: """Representation of an ILI9486 TFT.""" __LCD_WIDTH = 320 __LCD_HEIGHT = 480 - def __init__(self, spi: SpiDev, dc: int, rst: int = None, *, origin: Origin = Origin.UPPER_LEFT): + def __init__(self, spi: SpiDev, dc: int, rst: int = None, *, origin: Origin = Origin.UPPER_LEFT, sku: SKU = SKU.MPI3501): """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 @@ -98,6 +112,7 @@ def __init__(self, spi: SpiDev, dc: int, rst: int = None, *, origin: Origin = Or self.__dc = dc self.__rst = rst self.__origin = origin + self.__sku = sku self.__width = self.__LCD_WIDTH self.__height = self.__LCD_HEIGHT @@ -136,6 +151,52 @@ def is_landscape(self) -> bool: """Returns 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(0x66) + self.command(CMD_RDPXLFMT).data(0x66) + + 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_MADCTL).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_MADCTL).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(0x66) + self.command(CMD_SLPOUT) + self.command(CMD_MADCTL).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, is_data=True, chunk_size=4096): """Writes a byte or an array of bytes to the display.""" # dc low for command, high for data @@ -171,29 +232,11 @@ def reset(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) - self.command(CMD_RDPXLFMT).data(0x66) - - 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) + match self.__sku: + case SKU.MPI3501: + self.__mpi3501_init() + case SKU.MHS3528: + self.__mhs3528_init() return self def begin(self): diff --git a/README.md b/README.md index b80e54a..424a7e6 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ Python module to control an ILI9486 LCD. Based upon the deprecated Python ILI934 [Liqun Hu](https://github.com/huliqun/Myway_Python_ILI9486). Rewritten to use `spidev` and `rpi-lgpio` 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 @@ -43,8 +46,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/image.py b/image.py index 81c2375..8caa911 100644 --- a/image.py +++ b/image.py @@ -1,9 +1,11 @@ -from PIL import Image +import time + import RPi.GPIO as GPIO +from PIL import Image from spidev import SpiDev -import time -import ILI9486 as LCD + import config +import ILI9486 as LCD spi: SpiDev = None From 1f806670eccf8091d45596780a2e3982cefac14d Mon Sep 17 00:00:00 2001 From: SirLefti <62506842+SirLefti@users.noreply.github.com> Date: Tue, 12 May 2026 15:45:39 +0200 Subject: [PATCH 05/11] abstraction for GPIO back-ends --- README.md | 60 ++++++++++++------- config.py | 6 -- image.py | 73 ------------------------ pyproject.toml | 35 ++++++++++++ requirements.txt | 5 -- sample.png | Bin 75673 -> 0 bytes ILI9486.py => src/pyILI9486/__init__.py | 40 ++++++------- src/pyILI9486/gpio/__init__.py | 44 ++++++++++++++ src/pyILI9486/gpio/gpiod_facade.py | 51 +++++++++++++++++ src/pyILI9486/gpio/lgpio_facade.py | 40 +++++++++++++ src/pyILI9486/gpio/rpilgpio_facade.py | 42 ++++++++++++++ tests/test_e2e.py | 49 ++++++++++++++++ 12 files changed, 318 insertions(+), 127 deletions(-) delete mode 100644 config.py delete mode 100644 image.py create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 sample.png rename ILI9486.py => src/pyILI9486/__init__.py (92%) create mode 100644 src/pyILI9486/gpio/__init__.py create mode 100644 src/pyILI9486/gpio/gpiod_facade.py create mode 100644 src/pyILI9486/gpio/lgpio_facade.py create mode 100644 src/pyILI9486/gpio/rpilgpio_facade.py create mode 100644 tests/test_e2e.py diff --git a/README.md b/README.md index 424a7e6..e8b40eb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ 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) @@ -14,25 +15,44 @@ Rewritten to use `spidev` and `rpi-lgpio` instead of the discontinued Adafruit c 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 -```` - -Create a virtual environment: -````bash -python -m venv .venv -```` - -Install python dependencies: -````bash -.venv/bin/pip install -r requirements.txt -```` - -Run example: -````bash -.venv/bin/python image.py -```` +```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 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 + +# create GPIO facade (choose the one you already use in your project, or pick `gpiod` or `lgpio`) +from gpio.lgpio_facade import LGPIOFacade +gpio_facade = LGPIOFacade(DC_PIN, RS_PIN) + +# 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 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 8caa911..0000000 --- a/image.py +++ /dev/null @@ -1,73 +0,0 @@ -import time - -import RPi.GPIO as GPIO -from PIL import Image -from spidev import SpiDev - -import config -import ILI9486 as LCD - -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)) - 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)) - - while True: - print('Drawing image') - lcd.display(red) - lcd.display(green, x0 = width // 2) - lcd.display(blue, y0 = height // 2) - lcd.display(partial, x0 = width // 2, y0 = height // 2) - 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..0030dd5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "pyILI9486" +version = "0.0.1" +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.12" +license = { file = "LICENSE" } + +dependencies = [ + "Pillow>=12.0.0", + "spidev>=3.8", + "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", "twine"] +dev = ["isort", "pytest"] + +[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 8bf9eebe669b664df8388b8af42c94602d0b71f5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 75673 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 diff --git a/ILI9486.py b/src/pyILI9486/__init__.py similarity index 92% rename from ILI9486.py rename to src/pyILI9486/__init__.py index f9c029b..585fdc4 100644 --- a/ILI9486.py +++ b/src/pyILI9486/__init__.py @@ -23,10 +23,11 @@ from enum import Enum, IntEnum import numpy as np -import RPi.GPIO as GPIO from PIL import Image, ImageDraw from spidev import SpiDev +from pyILI9486.gpio import GPIOFacade, Pin + # commands CMD_RDPXLFMT = 0x0C @@ -103,14 +104,14 @@ class ILI9486: __LCD_WIDTH = 320 __LCD_HEIGHT = 480 - def __init__(self, spi: SpiDev, dc: int, rst: int = None, *, origin: Origin = Origin.UPPER_LEFT, sku: SKU = SKU.MPI3501): + def __init__(self, spi: SpiDev, gpio_facade: GPIOFacade, *, + origin: Origin = Origin.UPPER_LEFT, sku: SKU = SKU.MPI3501): """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.__gpio = gpio_facade self.__origin = origin self.__sku = sku @@ -119,13 +120,6 @@ def __init__(self, spi: SpiDev, dc: int, rst: int = None, *, origin: Origin = Or 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.is_landscape: self.__width, self.__height = self.__height, self.__width @@ -200,14 +194,14 @@ def __mhs3528_init(self): def send(self, data: int | list, 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 + 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 or an array of bytes to the display as a command.""" @@ -219,12 +213,12 @@ def data(self, data: int | list): def reset(self): """Resets the display if a reset pin is provided.""" - if self.__rst is not None: - GPIO.output(self.__rst, GPIO.HIGH) + 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 - GPIO.output(self.__rst, GPIO.LOW) + 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) - GPIO.output(self.__rst, GPIO.HIGH) + context.set_value(Pin.RS, True) time.sleep(.120) # wait 120 ms for finishing blanking and resetting self.__inverted = False self.__idle = False 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..dfb29d9 --- /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..ea17f2a --- /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..587d1a9 --- /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..064cd24 --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,49 @@ +import time +from typing import Type + +import pytest +from PIL import Image +from spidev import SpiDev + +from pyILI9486 import ILI9486, 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 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)) + + 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.clear() + lcd.reset() From dbfcb02dd74950cd16437f987fd89a1545e2aaef Mon Sep 17 00:00:00 2001 From: SirLefti <62506842+SirLefti@users.noreply.github.com> Date: Wed, 13 May 2026 13:32:31 +0200 Subject: [PATCH 06/11] drawing gradient in e2e --- tests/test_e2e.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 064cd24..1d65598 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1,6 +1,8 @@ +import colorsys import time from typing import Type +import numpy as np import pytest from PIL import Image from spidev import SpiDev @@ -26,6 +28,26 @@ def lcd(request): 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 @@ -34,6 +56,8 @@ def test_facade(lcd: ILI9486): 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) @@ -45,5 +69,9 @@ def test_facade(lcd: ILI9486): lcd.invert() time.sleep(1) + lcd.invert(False) + lcd.display(gradient) + time.sleep(1) + lcd.clear() lcd.reset() From fc2ebae5813696be9ee85af8c02d520408a974b9 Mon Sep 17 00:00:00 2001 From: SirLefti <62506842+SirLefti@users.noreply.github.com> Date: Tue, 19 May 2026 09:36:30 +0200 Subject: [PATCH 07/11] punctuation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e8b40eb..07ad8c7 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ purchasing products from Adafruit! Written by Tony DiCola for Adafruit Industries.
Adapted for ILI9486 by Liqun Hu.
-Modified and maintained by Thorben Yzer
+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 From 145de2b1fe2a1cfde7eeda67b6cca5be9e20988b Mon Sep 17 00:00:00 2001 From: SirLefti <62506842+SirLefti@users.noreply.github.com> Date: Tue, 19 May 2026 09:36:53 +0200 Subject: [PATCH 08/11] imports --- tests/test_e2e.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 1d65598..4a79eb4 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -7,7 +7,8 @@ from PIL import Image from spidev import SpiDev -from pyILI9486 import ILI9486, GPIOFacade +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 From 1253ee659be3ce4dbacf7dff05dc8024e4faf33c Mon Sep 17 00:00:00 2001 From: SirLefti <62506842+SirLefti@users.noreply.github.com> Date: Tue, 19 May 2026 09:37:30 +0200 Subject: [PATCH 09/11] MPI3501 uses RGB666, MHS3528 uses RGB565, added documentation --- src/pyILI9486/__init__.py | 247 +++++++++++++++++++++++++++++--------- 1 file changed, 188 insertions(+), 59 deletions(-) diff --git a/src/pyILI9486/__init__.py b/src/pyILI9486/__init__.py index 585fdc4..4212c16 100644 --- a/src/pyILI9486/__init__.py +++ b/src/pyILI9486/__init__.py @@ -21,12 +21,20 @@ # THE SOFTWARE. import time from enum import Enum, IntEnum +from typing import TYPE_CHECKING import numpy as np from PIL import Image, ImageDraw -from spidev import SpiDev -from pyILI9486.gpio import GPIOFacade, Pin +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 @@ -44,7 +52,7 @@ CMD_WRMEM = 0x2C CMD_RDMEM = 0x2E -CMD_MADCTL = 0x36 +CMD_MACCTL = 0x36 CMD_IDLOFF = 0x38 CMD_IDLON = 0x39 CMD_PXLFMT = 0x3A @@ -64,18 +72,41 @@ 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 + """ + 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.""" + 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. @@ -91,11 +122,28 @@ class Origin(IntEnum): LOWER_RIGHT_MIRRORED = 0x68 -def image_to_data(image: Image.Image) -> list: - """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() +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: @@ -104,12 +152,15 @@ class ILI9486: __LCD_WIDTH = 320 __LCD_HEIGHT = 480 - def __init__(self, spi: SpiDev, gpio_facade: GPIOFacade, *, - origin: Origin = Origin.UPPER_LEFT, sku: SKU = SKU.MPI3501): - """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.""" + 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 @@ -126,23 +177,35 @@ def __init__(self, spi: SpiDev, gpio_facade: GPIOFacade, *, self.__buffer = Image.new('RGB', (self.__width, self.__height), (0, 0, 0)) @property - def landscape_dimensions(self) -> tuple: - """Returns the display dimensions in landscape mode, no matter what mode is used""" + 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: - """Returns the display dimensions in portrait mode, no matter what mode is used""" + 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: - """Returns the current display dimensions""" + 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 true if selected origin is landscape mode; false otherwise""" + """ + 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): @@ -150,8 +213,8 @@ def __mpi3501_init(self): self.command(CMD_SLPOUT) # turns off the sleep mode time.sleep(0.020) - self.command(CMD_PXLFMT).data(0x66) - self.command(CMD_RDPXLFMT).data(0x66) + 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) @@ -159,7 +222,7 @@ def __mpi3501_init(self): 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_MADCTL).data(self.__origin.value) # memory address control + self.command(CMD_MACCTL).data(self.__origin.value) # memory address control self.command(CMD_SLPOUT) self.command(CMD_DISPON) @@ -171,7 +234,7 @@ def __mhs3528_init(self): self.command(0xF8).data([0x21, 0x04]) self.command(0xF9).data([0x00, 0x08]) - self.command(CMD_MADCTL).data(0x08) # memory address control - initial + self.command(CMD_MACCTL).data(0x08) # memory address control - initial self.command(CMD_DINVCTL).data(0x00) @@ -181,9 +244,9 @@ def __mhs3528_init(self): self.command(CMD_PGAMCTL).data(P_GAM_DEFAULT) self.command(CMD_NGAMCTL).data(N_GAM_DEFAULT) - self.command(CMD_PXLFMT).data(0x66) + self.command(CMD_PXLFMT).data(PixelFormat.from_sku(SKU.MHS3528)) self.command(CMD_SLPOUT) - self.command(CMD_MADCTL).data(self.__origin.value) # memory address control - final origin + self.command(CMD_MACCTL).data(self.__origin.value) # memory address control - final origin # Delay 255ms time.sleep(0.255) @@ -191,8 +254,14 @@ def __mhs3528_init(self): # Display On self.command(CMD_DISPON) - def send(self, data: int | list, is_data=True, chunk_size=4096): - """Writes a byte or an array of bytes to the display.""" + 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): @@ -204,15 +273,26 @@ def send(self, data: int | list, is_data=True, chunk_size=4096): return self def command(self, data: int): - """Writes a byte or an array of bytes to the display as a command.""" + """ + 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): - """Writes a byte or an array of bytes to the display as data.""" + 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 provided.""" + """ + 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 @@ -225,7 +305,10 @@ def reset(self): return self def _init_sequence(self): - """Initializes the display. Protected in case you want to override it for e.g. gamma control""" + """ + Initializes the display + :return: Self + """ match self.__sku: case SKU.MPI3501: self.__mpi3501_init() @@ -234,11 +317,23 @@ def _init_sequence(self): return self def begin(self): - """Initializes the display by resetting it and calling the init sequence.""" + """ + 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.""" + """ + 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: @@ -256,10 +351,14 @@ def set_window(self, x0: int = 0, y0: int = 0, x1: int | None = None, y1: int | 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. - If an image is provided, it should be in RGB format and the same - dimensions as the display.""" + """ + 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 @@ -270,30 +369,43 @@ def display(self, image: Image.Image | None = None, x0: int = 0, y0: int = 0): 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) + data = image_to_data(image, PixelFormat.from_sku(self.__sku)) self.command(CMD_WRMEM) self.data(data) return self - def clear(self, color=(0, 0, 0)): - """Clears the image buffer to the specified RGB color or black if not provided.""" + 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.""" + """ + 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.""" + """ + 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.""" + """ + 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: @@ -303,14 +415,19 @@ def invert(self, state: bool = True): @property def is_idle(self) -> bool: - """Returns the current idle state.""" + """ + 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.""" + """ + 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: @@ -319,21 +436,33 @@ def idle(self, state: bool = True): return self def on(self): - """Turns the display on.""" + """ + Turns the display on. + :return: Self + """ return self.command(CMD_DISPON) def off(self): - """Turns the display off.""" + """ + Turns the display off. + :return: Self + """ return self.command(CMD_DISPOFF) def sleep(self): - """Turns the displays sleep mode on""" + """ + 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""" + """ + Turns the displays sleep mode off. + :return: Self + """ self.command(CMD_SLPOUT) time.sleep(0.005) return self From ed0f2f8ad81ec1b6a865685efdd236f801663a50 Mon Sep 17 00:00:00 2001 From: SirLefti <62506842+SirLefti@users.noreply.github.com> Date: Wed, 20 May 2026 12:36:34 +0200 Subject: [PATCH 10/11] stuff for build and publish --- .gitignore | 2 ++ README.md | 4 ++-- pyproject.toml | 12 ++++++++---- src/{pyILI9486 => pyili9486}/__init__.py | 4 ++-- src/{pyILI9486 => pyili9486}/gpio/__init__.py | 0 src/{pyILI9486 => pyili9486}/gpio/gpiod_facade.py | 2 +- src/{pyILI9486 => pyili9486}/gpio/lgpio_facade.py | 2 +- src/{pyILI9486 => pyili9486}/gpio/rpilgpio_facade.py | 2 +- tests/test_e2e.py | 10 +++++----- 9 files changed, 22 insertions(+), 16 deletions(-) rename src/{pyILI9486 => pyili9486}/__init__.py (99%) rename src/{pyILI9486 => pyili9486}/gpio/__init__.py (100%) rename src/{pyILI9486 => pyili9486}/gpio/gpiod_facade.py (96%) rename src/{pyILI9486 => pyili9486}/gpio/lgpio_facade.py (94%) rename src/{pyILI9486 => pyili9486}/gpio/rpilgpio_facade.py (94%) 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/README.md b/README.md index 07ad8c7..e6ec0ce 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,11 @@ spi.mode = 0b10 # [CPOL|CPHA] -> polarity 1, phase 0 spi.max_speed_hz = 64000000 # create GPIO facade (choose the one you already use in your project, or pick `gpiod` or `lgpio`) -from gpio.lgpio_facade import LGPIOFacade +from pyili9486.gpio.lgpio_facade import LGPIOFacade gpio_facade = LGPIOFacade(DC_PIN, RS_PIN) # create the LCD instance -from pyILI9486 import ILI9486 +from pyili9486 import ILI9486 lcd = ILI9486(spi, gpio_facade) # draw some stuff diff --git a/pyproject.toml b/pyproject.toml index 0030dd5..17a71e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyILI9486" -version = "0.0.1" +version = "0.1.0" description = "display library for ILI9486 based SPI displays" authors = [ { name = "Tony Di Cola (tdicola)" }, @@ -10,12 +10,12 @@ authors = [ { name = "hemna" } ] readme = "README.md" -requires-python = ">=3.12" +requires-python = ">=3.11" license = { file = "LICENSE" } dependencies = [ "Pillow>=12.0.0", - "spidev>=3.8", + "spidev>=3.8; sys_platform == 'linux'", "numpy>=2.4.0" ] @@ -23,9 +23,13 @@ dependencies = [ gpiod = ["gpiod>=2.4.0"] rpilgpio = ["rpi-lgpio>=0.6"] lgpio = ["lgpio>=0.2.2.0"] -build = ["uv_build", "twine"] +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" diff --git a/src/pyILI9486/__init__.py b/src/pyili9486/__init__.py similarity index 99% rename from src/pyILI9486/__init__.py rename to src/pyili9486/__init__.py index 4212c16..380a448 100644 --- a/src/pyILI9486/__init__.py +++ b/src/pyili9486/__init__.py @@ -26,12 +26,12 @@ import numpy as np from PIL import Image, ImageDraw -from pyILI9486.gpio import Pin +from pyili9486.gpio import Pin if TYPE_CHECKING: from spidev import SpiDev - from pyILI9486.gpio import GPIOFacade + from pyili9486.gpio import GPIOFacade else: SpiDev = object GPIOFacade = object diff --git a/src/pyILI9486/gpio/__init__.py b/src/pyili9486/gpio/__init__.py similarity index 100% rename from src/pyILI9486/gpio/__init__.py rename to src/pyili9486/gpio/__init__.py diff --git a/src/pyILI9486/gpio/gpiod_facade.py b/src/pyili9486/gpio/gpiod_facade.py similarity index 96% rename from src/pyILI9486/gpio/gpiod_facade.py rename to src/pyili9486/gpio/gpiod_facade.py index dfb29d9..15e16d0 100644 --- a/src/pyILI9486/gpio/gpiod_facade.py +++ b/src/pyili9486/gpio/gpiod_facade.py @@ -1,7 +1,7 @@ import gpiod from gpiod.line import Direction, Value -from pyILI9486.gpio import GPIOContext, GPIOFacade, Pin, PinConfig, PinMap +from pyili9486.gpio import GPIOContext, GPIOFacade, Pin, PinConfig, PinMap class _GPIODContext(GPIOContext): diff --git a/src/pyILI9486/gpio/lgpio_facade.py b/src/pyili9486/gpio/lgpio_facade.py similarity index 94% rename from src/pyILI9486/gpio/lgpio_facade.py rename to src/pyili9486/gpio/lgpio_facade.py index ea17f2a..c2cc894 100644 --- a/src/pyILI9486/gpio/lgpio_facade.py +++ b/src/pyili9486/gpio/lgpio_facade.py @@ -2,7 +2,7 @@ import lgpio -from pyILI9486.gpio import GPIOContext, GPIOFacade, Pin, PinConfig, PinMap +from pyili9486.gpio import GPIOContext, GPIOFacade, Pin, PinConfig, PinMap class _LGPIOContext(GPIOContext): diff --git a/src/pyILI9486/gpio/rpilgpio_facade.py b/src/pyili9486/gpio/rpilgpio_facade.py similarity index 94% rename from src/pyILI9486/gpio/rpilgpio_facade.py rename to src/pyili9486/gpio/rpilgpio_facade.py index 587d1a9..e4e6285 100644 --- a/src/pyILI9486/gpio/rpilgpio_facade.py +++ b/src/pyili9486/gpio/rpilgpio_facade.py @@ -2,7 +2,7 @@ import RPi.GPIO as GPIO -from pyILI9486.gpio import GPIOContext, GPIOFacade, Pin, PinConfig, PinMap +from pyili9486.gpio import GPIOContext, GPIOFacade, Pin, PinConfig, PinMap class _RPiLGPIOContext(GPIOContext): diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 4a79eb4..ce36d6d 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -7,11 +7,11 @@ 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 +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=[ From 257e8c2a5db0f42881676e99a4a76698769ca171 Mon Sep 17 00:00:00 2001 From: SirLefti <62506842+SirLefti@users.noreply.github.com> Date: Wed, 20 May 2026 13:03:41 +0200 Subject: [PATCH 11/11] publish workflow --- .github/workflows/python-publish.yml | 29 ++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/python-publish.yml 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/pyproject.toml b/pyproject.toml index 17a71e7..1c71226 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyILI9486" -version = "0.1.0" +version = "1.0.0" description = "display library for ILI9486 based SPI displays" authors = [ { name = "Tony Di Cola (tdicola)" },