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()