Last active
May 8, 2026 18:16
-
-
Save hemna/b3f2dfb0773d9378851c86337d09d7e5 to your computer and use it in GitHub Desktop.
MHS35b (ILI9486) variant driver - fixed init sequence matching DTS exactly
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ILI9486.py — MHS35b/MHS35 variant (Pi 5 compatible, lgpio) | |
| # | |
| # Original: SirLefti/Python_ILI9486 (Waveshare) | |
| # Modified: craigerl/hemna — MHS35b/MHS35 support + Pi 5 | |
| # | |
| # Key differences from original: | |
| # - Uses lgpio (Pi 5 compatible) instead of RPi.GPIO | |
| # - regwidth=16: commands AND register data sent as [0x00, byte] pairs | |
| # - Pixel data (bulk framebuffer writes) sent as raw bytes | |
| # - Pixel format: RGB565 (0x55) instead of RGB666 (0x66) | |
| # - image_to_data() outputs RGB565 (2 bytes/pixel) | |
| # - MHS35b init sequence from device tree overlay | |
| # | |
| # DTS source: https://github.com/SirLefti/Python_ILI9486/issues/6 | |
| # LCD wiki: https://www.lcdwiki.com/MHS-3.5inch_RPi_Display | |
| # | |
| # Requirements: | |
| # pip install lgpio spidev numpy pillow | |
| # | |
| # Usage: | |
| # from spidev import SpiDev | |
| # from MHS35 import ILI9486 | |
| # | |
| # spi = SpiDev() | |
| # spi.open(0, 0) | |
| # spi.max_speed_hz = 16000000 | |
| # spi.mode = 0 | |
| # d = ILI9486(spi, dc=24, rst=25) | |
| # d.begin() | |
| # d.clear((255, 0, 0)) | |
| # d.display() | |
| # | |
| # Copyright (c) | |
| # Authors: Tony DiCola, Liqun Hu, Thorben Yzer | |
| # MHS35b modifications: 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. | |
| from enum import Enum | |
| import time | |
| import numpy as np | |
| from PIL import Image, ImageDraw | |
| import lgpio | |
| 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) -> list: | |
| """Converts a PIL image to RGB565 format (16-bit, 2 bytes per pixel). | |
| RGB565 packing: | |
| Byte 1 (high): RRRRRGGG | |
| Byte 2 (low): GGGBBBBB | |
| """ | |
| pb = np.array(image.convert('RGB')).astype('uint16') | |
| r = pb[:, :, 0] | |
| g = pb[:, :, 1] | |
| b = pb[:, :, 2] | |
| # Pack into RGB565: 5 bits red, 6 bits green, 5 bits blue | |
| rgb565 = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3) | |
| # Split 16-bit values into high byte and low byte | |
| high = ((rgb565 >> 8) & 0xFF).astype('uint8') | |
| low = (rgb565 & 0xFF).astype('uint8') | |
| # Interleave high and low bytes: [H0, L0, H1, L1, ...] | |
| result = np.empty((rgb565.shape[0], rgb565.shape[1], 2), dtype='uint8') | |
| result[:, :, 0] = high | |
| result[:, :, 1] = low | |
| return result.flatten().tolist() | |
| class Origin(Enum): | |
| """Representation of the display origin.""" | |
| # data: MY | MX | MV | ML | BGR | MH | X | X | |
| 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: | |
| """ILI9486 TFT LCD driver — MHS35b/MHS35 variant. | |
| Uses lgpio (Pi 5 compatible). | |
| 16-bit register interface: commands and register data are sent as [0x00, byte]. | |
| Pixel data (bulk framebuffer writes after WRMEM) is sent as raw bytes. | |
| """ | |
| @classmethod | |
| def landscape_dimensions(cls) -> tuple: | |
| return LCD_HEIGHT, LCD_WIDTH | |
| @classmethod | |
| def portrait_dimensions(cls) -> tuple: | |
| return LCD_WIDTH, LCD_HEIGHT | |
| def __init__(self, spi: SpiDev, dc: int, rst: int = None, *, | |
| origin: Origin = Origin.UPPER_LEFT, gpio_chip: int = 4): | |
| """ | |
| Args: | |
| spi: SpiDev instance (already opened with spi.open()) | |
| dc: BCM GPIO pin number for Data/Command | |
| rst: BCM GPIO pin number for Reset (optional) | |
| origin: Display orientation (default UPPER_LEFT = landscape) | |
| gpio_chip: GPIO chip number (Pi 5 = 4, Pi 4/3 = 0) | |
| """ | |
| 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 | |
| # Open GPIO chip | |
| self.__gpio = lgpio.gpiochip_open(gpio_chip) | |
| lgpio.gpio_claim_output(self.__gpio, self.__dc, 1) | |
| if self.__rst is not None: | |
| lgpio.gpio_claim_output(self.__gpio, self.__rst, 1) | |
| # Swap width and height if landscape mode (MV bit set) | |
| 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 __del__(self): | |
| try: | |
| lgpio.gpiochip_close(self.__gpio) | |
| except Exception: | |
| pass | |
| def dimensions(self) -> tuple: | |
| return self.__width, self.__height | |
| def is_landscape(self) -> bool: | |
| return bool(self.__origin.value & 0x20) | |
| def command(self, data): | |
| """Send command byte: DC low, 16-bit padded [0x00, cmd].""" | |
| lgpio.gpio_write(self.__gpio, self.__dc, 0) | |
| if isinstance(data, int): | |
| self.__spi.writebytes([0x00, data]) | |
| else: | |
| for byte in data: | |
| self.__spi.writebytes([0x00, byte]) | |
| return self | |
| def data(self, data): | |
| """Send register data: DC high, 16-bit padded [0x00, byte] per byte.""" | |
| lgpio.gpio_write(self.__gpio, self.__dc, 1) | |
| if isinstance(data, int): | |
| self.__spi.writebytes([0x00, data]) | |
| else: | |
| for byte in data: | |
| self.__spi.writebytes([0x00, byte]) | |
| return self | |
| def data_bulk(self, data, chunk_size=4096): | |
| """Send pixel data: DC high, raw bytes (no 16-bit padding).""" | |
| lgpio.gpio_write(self.__gpio, self.__dc, 1) | |
| if isinstance(data, list): | |
| for start in range(0, len(data), chunk_size): | |
| end = min(start + chunk_size, len(data)) | |
| self.__spi.writebytes(data[start:end]) | |
| else: | |
| self.__spi.writebytes(data) | |
| return self | |
| def reset(self): | |
| """Resets the display if a reset pin is provided.""" | |
| if self.__rst is not None: | |
| lgpio.gpio_write(self.__gpio, self.__rst, 1) | |
| time.sleep(.001) | |
| lgpio.gpio_write(self.__gpio, self.__rst, 0) | |
| time.sleep(.001) | |
| lgpio.gpio_write(self.__gpio, self.__rst, 1) | |
| time.sleep(.120) | |
| self.__inverted = False | |
| self.__idle = False | |
| return self | |
| def _init_sequence(self): | |
| """MHS35b/MHS35 initialization sequence from device tree overlay. | |
| DTS init (mhs35-overlay.dts, tft35a-overlay, mhs35b-overlay all identical): | |
| 0x10000f1 0x36 0x04 0x00 0x3c 0x0f 0x8f | |
| 0x10000f2 0x18 0xa3 0x12 0x02 0xb2 0x12 0xff 0x10 0x00 | |
| 0x10000f8 0x21 0x04 | |
| 0x10000f9 0x00 0x08 | |
| 0x1000036 0x08 | |
| 0x10000b4 0x00 | |
| 0x10000c1 0x41 | |
| 0x10000c5 0x00 0x91 0x80 0x00 | |
| 0x10000e0 ... (15 bytes positive gamma) | |
| 0x10000e1 ... (15 bytes negative gamma) | |
| 0x100003a 0x55 | |
| 0x1000011 | |
| 0x1000036 0x28 | |
| 0x20000ff | |
| 0x1000029 | |
| """ | |
| # 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]) | |
| # MADCTL initial | |
| self.command(CMD_MADCTL).data(0x08) | |
| # Display Inversion Control | |
| self.command(0xB4).data(0x00) | |
| # Power Control 2 | |
| self.command(0xC1).data(0x41) | |
| # VCOM Control | |
| self.command(CMD_VCOMCTL).data([0x00, 0x91, 0x80, 0x00]) | |
| # Positive Gamma Control | |
| self.command(CMD_PGAMCTL).data([0x0F, 0x1F, 0x1C, 0x0C, 0x0F, 0x08, 0x48, 0x98, | |
| 0x37, 0x0A, 0x13, 0x04, 0x11, 0x0D, 0x00]) | |
| # Negative Gamma Control | |
| self.command(CMD_NGAMCTL).data([0x0F, 0x32, 0x2E, 0x0B, 0x0D, 0x05, 0x47, 0x75, | |
| 0x37, 0x06, 0x10, 0x03, 0x24, 0x20, 0x00]) | |
| # Pixel Format — RGB565 | |
| self.command(CMD_PXLFMT).data(0x55) | |
| # Sleep Out | |
| self.command(CMD_SLPOUT) | |
| # MADCTL — final origin | |
| self.command(CMD_MADCTL).data(self.__origin.value) | |
| # Delay 255ms | |
| time.sleep(0.255) | |
| # Display On | |
| 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) | |
| self.data([x0 >> 8, x0 & 0xFF, x1 >> 8, x1 & 0xFF]) | |
| self.command(CMD_SETPA) | |
| self.data([y0 >> 8, y0 & 0xFF, y1 >> 8, y1 & 0xFF]) | |
| return self | |
| def display(self, image=None, x0=0, y0=0): | |
| """Writes the display buffer or provided image to 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) | |
| # Send pixel data as raw bulk bytes (not 16-bit padded) | |
| data = image_to_data(image) | |
| self.command(CMD_WRMEM) | |
| self.data_bulk(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 image(self) -> Image: | |
| """Returns the image buffer.""" | |
| return self.__buffer | |
| def is_inverted(self) -> bool: | |
| return self.__inverted | |
| def invert(self, state: bool = True): | |
| if state: | |
| self.command(CMD_INVON) | |
| else: | |
| self.command(CMD_INVOFF) | |
| self.__inverted = state | |
| return self | |
| def is_idle(self) -> bool: | |
| return self.__idle | |
| def idle(self, state: bool = True): | |
| if state: | |
| self.command(CMD_IDLON) | |
| else: | |
| self.command(CMD_IDLOFF) | |
| self.__idle = state | |
| return self | |
| def on(self): | |
| return self.command(CMD_DISPON) | |
| def off(self): | |
| return self.command(CMD_DISPOFF) | |
| def sleep(self): | |
| self.command(CMD_SLPIN) | |
| time.sleep(0.005) | |
| return self | |
| def wake_up(self): | |
| self.command(CMD_SLPOUT) | |
| time.sleep(0.005) | |
| return self |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # show_image.py — Display an image on the MHS35b LCD | |
| # | |
| # Usage: python3 show_image.py /path/to/image.png | |
| import sys | |
| from spidev import SpiDev | |
| from PIL import Image | |
| from MHS35 import ILI9486 | |
| if len(sys.argv) < 2: | |
| print("Usage: python3 show_image.py /path/to/image.png") | |
| sys.exit(1) | |
| # Open and resize image to fit display (480x320 landscape) | |
| img = Image.open(sys.argv[1]).convert('RGB') | |
| img = img.resize((480, 320)) | |
| # Init display | |
| spi = SpiDev() | |
| spi.open(0, 0) | |
| spi.max_speed_hz = 16000000 | |
| spi.mode = 0 | |
| disp = ILI9486(spi, dc=24, rst=25, gpio_chip=4) | |
| disp.begin() | |
| disp.display(img) | |
| print("Done") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # test_display.sh — Download and test the MHS35b ILI9486 driver on Pi 5 | |
| # | |
| # Run on your Pi: | |
| # chmod +x test_display.sh && ./test_display.sh | |
| set -e | |
| # Install lgpio if not present | |
| python3 -c "import lgpio" 2>/dev/null || pip install lgpio | |
| # Download the modified driver | |
| wget -O /home/pi/MHS35.py "https://gist.githubusercontent.com/hemna/b3f2dfb0773d9378851c86337d09d7e5/raw/MHS35.py" | |
| echo "Driver downloaded. Running display test..." | |
| python3 << 'EOF' | |
| from spidev import SpiDev | |
| from MHS35 import ILI9486 | |
| spi = SpiDev() | |
| spi.open(0, 0) | |
| spi.max_speed_hz = 16000000 | |
| spi.mode = 0 | |
| d = ILI9486(spi, dc=24, rst=25, gpio_chip=4) | |
| d.begin() | |
| d.clear((255, 0, 0)) | |
| d.display() | |
| print("Done - screen should be solid red") | |
| EOF |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment