Skip to content

Instantly share code, notes, and snippets.

@hemna
Last active May 7, 2026 16:13
Show Gist options
  • Select an option

  • Save hemna/c17e640779e7de28cc83308bf72853ae to your computer and use it in GitHub Desktop.

Select an option

Save hemna/c17e640779e7de28cc83308bf72853ae to your computer and use it in GitHub Desktop.
Analysis: MHS35b (ILI9486) variant support for Python_ILI9486 driver — Issue #6
# ILI9486_MHS35b.py — MHS35b variant support for Python_ILI9486
#
# Drop-in subclass of ILI9486 that provides:
# 1. MHS35b-specific init sequence (manufacturer registers, power/VCOM tuning)
# 2. RGB565 pixel format (16-bit) instead of RGB666 (18-bit)
#
# Usage:
# from ILI9486_MHS35b import ILI9486_MHS35b, image_to_data_565
# spi = SpiDev()
# spi.open(0, 0)
# spi.max_speed_hz = 16000000
# display = ILI9486_MHS35b(spi, dc=24, rst=25)
# display.begin()
# display.display(my_pil_image)
#
# Author: craigerl / hemna
# Reference: https://github.com/SirLefti/Python_ILI9486/issues/6
# License: MIT (same as parent project)
#
# NOTES on undocumented registers (from issue #6 discussion):
# Registers 0xF1, 0xF2, 0xF8, 0xF9 do NOT appear in the official ILI9486L
# spec sheet (https://www.displayfuture.com/Display/datasheet/controller/ILI9486L.pdf).
# They are manufacturer-specific panel calibration values extracted from the
# MHS35b device tree overlay. If your MHS35b revision behaves unexpectedly,
# these registers are the first candidates to comment out or adjust.
#
# DTS init format reference (fbtft):
# 0x10000XX = send command 0xXX
# bare bytes following a command = data for that command
# 0x20000XX = delay of XX milliseconds
import time
import numpy as np
from PIL import Image
from ILI9486 import ILI9486, Origin, CMD_SLPOUT, CMD_PXLFMT, CMD_MADCTL, \
CMD_VCOMCTL, CMD_PGAMCTL, CMD_NGAMCTL, CMD_DISPON, CMD_WRMEM
def image_to_data_565(image: Image) -> list:
"""Converts a PIL image to 565RGB format (16-bit, 2 bytes per pixel).
RGB565 packing:
Byte 1 (high): RRRRRGGG
Byte 2 (low): GGGBBBBB
This is 33% less data per frame than RGB666 (2 bytes vs 3 bytes per pixel),
resulting in faster SPI transfers.
"""
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
low = rgb565 & 0xFF
# Interleave high and low bytes
result = np.empty((rgb565.shape[0], rgb565.shape[1], 2), dtype='uint16')
result[:, :, 0] = high
result[:, :, 1] = low
return result.flatten().astype('uint8').tolist()
class ILI9486_MHS35b(ILI9486):
"""ILI9486 driver subclass for the MHS35b display variant.
The MHS35b is a popular, cheaper alternative to the Waveshare ILI9486.
Key differences from Waveshare:
- Uses RGB565 (16-bit) pixel format instead of RGB666 (18-bit)
- Requires manufacturer-specific register initialization (0xF1, 0xF2, 0xF8, 0xF9)
- Different power control and VCOM voltage settings
- Same GPIO pinout (DC=24, RST=25 by default)
The RGB565 format is actually more efficient — 2 bytes/pixel vs 3 — so
frame updates are ~33% faster than the Waveshare variant.
NOTE: The parent class uses double-underscore name mangling on its instance
variables (__origin, __buffer, __width, __height). This subclass accesses
them via _ILI9486__name. If SirLefti ever changes these to single-underscore
protected attrs, this subclass can be simplified.
"""
def _init_sequence(self):
"""MHS35b-specific initialization sequence.
Derived from the MHS35b device tree overlay:
https://github.com/goodtft/LCD-show/blob/master/MHS35B-show
The init order follows the DTS exactly. Key differences from Waveshare:
- Manufacturer registers 0xF1/F2/F8/F9 (not in ILI9486L spec sheet)
- Power Control uses 0xC1 (not 0xC2)
- VCOM has non-zero contrast values
- Pixel format is 0x55 (RGB565) not 0x66 (RGB666)
- Longer stabilization delay (255ms vs 20ms)
"""
# --- Manufacturer-specific registers (panel calibration) ---
# These are NOT in the official ILI9486L datasheet.
# They come from the goodtft MHS35b overlay and are likely
# internal panel timing/voltage calibration for this specific LCD module.
# If display shows artifacts, try commenting these out one at a time.
self.command(0xF1).send([0x36, 0x04, 0x00, 0x3C, 0x0F, 0x8F], True, chunk_size=1)
self.command(0xF2).send([0x18, 0xA3, 0x12, 0x02, 0xB2, 0x12, 0xFF, 0x10, 0x00], True, chunk_size=1)
self.command(0xF8).send([0x21, 0x04], True, chunk_size=1)
self.command(0xF9).send([0x00, 0x08], True, chunk_size=1)
# --- Standard ILI9486 registers with MHS35b-specific values ---
# Memory access control (initial: portrait, RGB order)
# This gets overwritten later with the landscape/BGR origin value
self.command(CMD_MADCTL).data(0x08)
# Display inversion control — column inversion
self.command(0xB4).data(0x00)
# Power Control 2 (note: Waveshare uses 0xC2/Power Control Normal instead)
self.command(0xC1).data(0x41)
# VCOM control — non-zero values for MHS35b contrast/voltage tuning
# Waveshare uses [0x00, 0x00, 0x00, 0x00]
self.command(CMD_VCOMCTL).send([0x00, 0x91, 0x80, 0x00], True, chunk_size=1)
# Positive gamma control (MHS35b-tuned curve)
self.command(CMD_PGAMCTL) \
.send([0x0F, 0x1F, 0x1C, 0x0C, 0x0F, 0x08, 0x48, 0x98,
0x37, 0x0A, 0x13, 0x04, 0x11, 0x0D, 0x00], True, chunk_size=1)
# Negative gamma control (MHS35b-tuned curve)
self.command(CMD_NGAMCTL) \
.send([0x0F, 0x32, 0x2E, 0x0B, 0x0D, 0x05, 0x47, 0x75,
0x37, 0x06, 0x10, 0x03, 0x24, 0x20, 0x00], True, chunk_size=1)
# Pixel format: RGB565 (16-bit, 2 bytes per pixel)
# THIS is the critical difference from Waveshare (which uses 0x66 = RGB666)
self.command(CMD_PXLFMT).data(0x55)
# Sleep out — must come before display on
self.command(CMD_SLPOUT)
# Set final MADCTL with user-selected origin (landscape, BGR by default)
# The DTS sets 0x28 which matches Origin.UPPER_LEFT
self.command(CMD_MADCTL).data(self._ILI9486__origin.value)
# Wait for display to stabilize
# MHS35b DTS specifies 0xFF = 255ms delay here (vs Waveshare's 20ms)
time.sleep(0.260)
# Display on
self.command(CMD_DISPON)
def display(self, image=None, x0=0, y0=0):
"""Writes an image to the display using RGB565 pixel format.
Overrides the parent to use image_to_data_565() instead of image_to_data().
The RGB565 format sends 2 bytes per pixel (vs 3 for RGB666), so a full
480x320 frame is 307,200 bytes instead of 460,800 — 33% faster over SPI.
"""
if image is None:
image = self._ILI9486__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._ILI9486__width or y1 >= self._ILI9486__height or x0 < 0 or y0 < 0:
raise ValueError(
'Image exceeds display bounds ({0}x{1})'.format(
self._ILI9486__width, self._ILI9486__height))
self.set_window(x0, y0, x1, y1)
data = image_to_data_565(image)
self.command(CMD_WRMEM)
if isinstance(data, list):
self.data(data)
return self

MHS35b (ILI9486) Variant — Analysis of GitHub Issue #6

Issue: SirLefti/Python_ILI9486#6
Author: @craigerl
Date: April 27, 2026
Labels: enhancement, help wanted


Summary

The MHS35b is a popular, cheaper ILI9486-based 3.5" TFT display that is increasingly used as an alternative to the harder-to-find Waveshare ILI9486. The SirLefti Python_ILI9486 driver was written for the Waveshare variant.

@craigerl decompiled the device tree overlay from the goodtft/LCD-show project and posted the full DTS source, hoping it reveals what tweaks are needed to get the MHS35b working with this Python driver.


Discussion Thread (5 comments)

@SirLefti (maintainer) — Apr 27, 2026:

Hi mate, long time no see. I am not sure how I can work on that, because I do not own that display module. If you got a working patch, I'd be probably able to assist you in bringing it together in the same code base, if it makes sense to do so. I think this is related to #4 right? I developed this lib using the ILI9486 spec sheet. I see an init sequence in the file you shared, but I am not sure what it tells me.

@craigerl — Apr 27, 2026:

Thanks again for all the support here - great work. I just noticed the string of bytes for "Initialize" was very similar (almost identical) in both the dtoverlay and your ili9486 driver here. I'll keep poking at it, but will otherwise leave the dtoverlay here in case somebody thinks of a way to integrate it with this project.

@SirLefti (maintainer) — Apr 27, 2026:

Yes, I see some similarities. Do you see the 0x10000e0 and 0x10000e1 with their following data? e0 and e1 are the positive and negative gamma control commands (PGAMCTL and NGAMCTL) in ILI9486. They are most likely the same thing here as well. But the other commands not known to me, and they do not appear in the spec sheet I used to implement the commands back then.

If you want to try your luck, all 0x10000.. bytes are most likely commands, the shorter ones are data. You can change the init sequence and see if it works. For the commands, you can maybe use just the last two digits and ignore the long 0x10000 part, not sure about that.

@snakefood3232 — Apr 27, 2026:

The device tree overlay you mentioned for the MHS35b ILI9486 variant has similarities that could be leveraged to tweak the existing driver. I'd handle this by examining the initialization command bytes and adjusting the driver accordingly to accommodate these differences. Can have a PR up within 2 days. I've worked on similar integrations with SPI-based displays, ensuring compatibility across various hardware variations.

@craigerl — Apr 27, 2026:

I thought you might have some good insights. I went a few bytes into the init string on the dtoverlay and started picking out LOTS of consecutive similarities. I can send you a screen too.

Key Takeaways from Discussion

  1. SirLefti doesn't own an MHS35b — cannot test, but willing to merge a working patch
  2. SirLefti correctly identified that 0x10000XX = command 0xXX in fbtft format
  3. SirLefti noted the 0xF1/F2/F8/F9 registers are not in the official ILI9486L spec sheet — they're manufacturer-specific
  4. Related to issue #4 — likely another display variant compatibility request
  5. @snakefood3232 volunteered a PR within 2 days (status unknown)

Decoded MHS35b Init Sequence (from DTS)

The DTS init property uses the fbtft format where:

  • 0x1000000X = send command 0xXX
  • Following bare bytes = data for that command
  • 0x20000XX = delay of XX ms
Step Command Name Data
1 0xF1 Manufacturer-specific [0x36, 0x04, 0x00, 0x3C, 0x0F, 0x8F]
2 0xF2 Manufacturer-specific [0x18, 0xA3, 0x12, 0x02, 0xB2, 0x12, 0xFF, 0x10, 0x00]
3 0xF8 Manufacturer-specific [0x21, 0x04]
4 0xF9 Manufacturer-specific [0x00, 0x08]
5 0x36 MADCTL (Memory Access Control) [0x08]
6 0xB4 Display Inversion Control [0x00]
7 0xC1 Power Control 2 [0x41]
8 0xC5 VCOM Control [0x00, 0x91, 0x80, 0x00]
9 0xE0 Positive Gamma Control [0x0F, 0x1F, 0x1C, 0x0C, 0x0F, 0x08, 0x48, 0x98, 0x37, 0x0A, 0x13, 0x04, 0x11, 0x0D, 0x00]
10 0xE1 Negative Gamma Control [0x0F, 0x32, 0x2E, 0x0B, 0x0D, 0x05, 0x47, 0x75, 0x37, 0x06, 0x10, 0x03, 0x24, 0x20, 0x00]
11 0x3A Pixel Format [0x55] (16-bit RGB565)
12 0x11 Sleep Out
13 0x36 MADCTL [0x28] (landscape, BGR)
14 Delay 255 ms
15 0x29 Display On

Current Waveshare Driver Init Sequence

From SirLefti/Python_ILI9486 — the _init_sequence() method:

Step Command Name Data
1 0xB0 Interface Mode Control [0x00]
2 0x11 Sleep Out
3 Delay 20 ms
4 0x3A Pixel Format [0x66] (18-bit RGB666)
5 0x36 MADCTL [origin] (default 0x28)
6 0xC2 Power Control Normal [0x44]
7 0xC5 VCOM Control [0x00, 0x00, 0x00, 0x00]
8 0xE0 Positive Gamma Control [0x0F, 0x1F, ...]
9 0xE1 Negative Gamma Control [0x0F, 0x1B, ...]
10 0x29 Display On

Key Differences

Register MHS35b (DTS) Waveshare (driver) Impact
0x3A (Pixel Format) 0x55 — 16-bit RGB565 0x66 — 18-bit RGB666 CRITICAL — completely different pixel data packing
Power Control 0xC1[0x41] 0xC2[0x44] Different register entirely
0xC5 (VCOM) [0x00, 0x91, 0x80, 0x00] [0x00, 0x00, 0x00, 0x00] Affects contrast/voltage levels
0xF1, 0xF2, 0xF8, 0xF9 Manufacturer-specific tuning bytes Not used Panel calibration — likely required
0xB4 (Inversion Ctrl) [0x00] Not set
0xB0 (Interface Mode) Not set [0x00]
Gamma (0xE0/0xE1) Different curve values Different curve values Color tuning
MADCTL (0x36) Final value 0x28 Default 0x28 Same — orientation is compatible

Root Cause Analysis

The primary blocker is the pixel format mismatch:

  • Waveshare uses 18-bit RGB666 (0x66): each pixel is 3 bytes (6 bits per channel, MSB-aligned with 2 LSBs masked)
  • MHS35b expects 16-bit RGB565 (0x55): each pixel is 2 bytes (5-red, 6-green, 5-blue packed)

The current driver's image_to_data() function produces 666-format data:

# Current: masks to 6-bit per channel, outputs 3 bytes/pixel
np.dstack((pb[:, :, 0] & 0xFC, pb[:, :, 1] & 0xFC, pb[:, :, 2] & 0xFC)).flatten().tolist()

For MHS35b, this would need to produce 565-format data:

# Needed: pack into 2 bytes/pixel as RGB565
color = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
# Then split into high byte and low byte

The secondary blockers are the missing manufacturer-specific registers (0xF1, 0xF2, 0xF8, 0xF9) and different power/VCOM settings that the MHS35b panel requires for proper operation.


Recommendations for Implementation

Option A: Subclass (cleanest)

The driver already marks _init_sequence() as protected for exactly this use case:

class ILI9486_MHS35b(ILI9486):
    def _init_sequence(self):
        # MHS35b-specific init with 0xF1/F2/F8/F9 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_SLPOUT)
        time.sleep(0.255)
        self.command(CMD_PXLFMT).data(0x55)  # RGB565
        self.command(CMD_MADCTL).data(self._origin.value)
        self.command(0xB4).data(0x00)
        self.command(0xC1).data(0x41)
        self.command(CMD_VCOMCTL).data([0x00, 0x91, 0x80, 0x00])
        self.command(CMD_PGAMCTL).data([...])
        self.command(CMD_NGAMCTL).data([...])
        self.command(CMD_DISPON)

Option B: Configuration parameter

Add a variant='waveshare'|'mhs35b' parameter to __init__ that selects the init sequence and pixel format converter.

Required Changes Either Way

  1. New pixel conversion functionimage_to_data_565() for RGB565 packing (2 bytes/pixel vs 3)
  2. Alternate init sequence — the MHS35b register writes
  3. SPI throughput — RGB565 is 33% less data per frame (2 vs 3 bytes/pixel), so the MHS35b should actually be faster

DTS Hardware Configuration

From the overlay, the MHS35b uses:

  • SPI bus 0, chip select 0 (reg = <0x00>)
  • Reset GPIO: BCM pin 25 (active low)
  • DC GPIO: BCM pin 24 (active high)
  • Bus width: 8-bit
  • Register width: 16-bit
  • Compatible: ilitek,ili9486 (same chip, different panel tuning)
  • SPI speed: configurable via overlay parameter

These are the same pins the Waveshare uses by default, so no wiring changes needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment