Skip to content

Commit

Permalink
ILI9486 (Waveshare 3.5-inch (B)) support (#136)
Browse files Browse the repository at this point in the history
* Initial ILI9486 (Waveshare 3.5-inch (B)) support

* Create test_ili9486.py

Starting on tests for ILI9486.  So far, test_init_320x480 is now passing.  Need to get tests_offsets and test_display_full_frame healthy.

* Create demo_ili9486.json

* Get other tests for ILI9486 working

* Whitespace cleanup

* Update python-usage.rst with some ILI9486 details

* Comment updates from review

Co-authored-by: Richard Hull <rm_hull@yahoo.co.uk>
  • Loading branch information
mattblovell and rm-hull committed Dec 12, 2020
1 parent dd39609 commit 40ebe37
Show file tree
Hide file tree
Showing 6 changed files with 329 additions and 3 deletions.
1 change: 1 addition & 0 deletions CONTRIBUTING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ Contributors
* Ricardo Amendoeira (@ric2b)
* Kevin Stone (@kevinastone)
* Dhrone (@dhrone)
* Matthew Lovell (@mattblovell)
8 changes: 6 additions & 2 deletions doc/python-usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,16 @@ In this example, we are using an SPI interface with a pcd8544 display.
from luma.core.interface.serial import i2c, spi, parallel, pcf8574
from luma.core.render import canvas
from luma.lcd.device import pcd8544, st7735, st7567, uc1701x, ili9341, hd44780
from luma.lcd.device import pcd8544, st7735, st7567, uc1701x, ili9341, ili9486, hd44780
serial = spi(port=0, device=0, gpio_DC=23, gpio_RST=24)
device = pcd8544(serial)
The display device should now be properly configured.

The :py:class:`~luma.lcd.device.pcd8544`, :py:class:`~luma.lcd.device.st7735`,
:py:class:`~luma.lcd.device.st7567`, :py:class:`~luma.lcd.device.uc1701x`, :py:class:`~luma.lcd.device.ili9341` and :py:class:`~luma.lcd.device.hd44780`
:py:class:`~luma.lcd.device.st7567`, :py:class:`~luma.lcd.device.uc1701x`, :py:class:`~luma.lcd.device.ili9341`,
:py:class:`~luma.lcd.device.ili9486` and :py:class:`~luma.lcd.device.hd44780`
classes all expose a :py:meth:`~luma.lcd.device.pcd8544.display` method which
takes an image with attributes consistent with the capabilities of the device.

Expand Down Expand Up @@ -101,6 +102,9 @@ properties reflect the rotated dimensions rather than the physical dimensions.

The HD44780 does not support display rotation.

The ILI9486 display defaults to a portrait orientation (320x480), and rotation
is required to use the display in landscape mode.

Seven-Segment Drivers
---------------------
The HT1621 is driven with the :py:class:`luma.lcd.device.ht1621` class, but is
Expand Down
5 changes: 5 additions & 0 deletions luma/lcd/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ class ili9341(object):
DISPLAYOFF = 0x28


class ili9486(object):
DISPLAYON = 0x29
DISPLAYOFF = 0x28


class ht1621(object):
DISPLAYON = 0x06
DISPLAYOFF = 0x04
Expand Down
158 changes: 157 additions & 1 deletion luma/lcd/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
from luma.core.virtual import character
from luma.core.bitmap_font import embedded_fonts

__all__ = ["pcd8544", "st7735", "ht1621", "uc1701x", "st7567", "ili9341", "hd44780"]
__all__ = ["pcd8544", "st7735", "ht1621", "uc1701x", "st7567", "ili9341", "ili9486", "hd44780"]


class GPIOBacklight:
Expand Down Expand Up @@ -649,6 +649,162 @@ def command(self, cmd, *args):
self._serial_interface.data(list(args))


class ili9486(backlit_device, __framebuffer_mixin):
"""
Serial interface to a 262k color (6-6-6 RGB) ILI9486 LCD display.
On creation, an initialization sequence is pumped to the display to properly
configure it. Further control commands can then be called to affect the
brightness (if implemented) and other settings.
Note that the ILI9486 display used for development -- a Waveshare
3.5-inch IPS LCD(B) -- used a portrait orientation. Images were
rendered correctly only when specifying that height was 480 pixels
and the width was 320.
:param serial_interface: the serial interface (usually a
:py:class:`luma.core.interface.serial.spi` instance) to delegate sending
data and commands through.
:param width: The number of pixels laid out horizontally.
:type width: int
:param height: The number of pixels laid out vertically.
:type height: int
:param rotate: An integer value of 0 (default), 1, 2 or 3 only, where 0 is
no rotation, 1 is rotate 90° clockwise, 2 is 180° rotation and 3
represents 270° rotation.
:type rotate: int
:param framebuffer: Framebuffering strategy, currently instances of
``diff_to_previous()`` or ``full_frame()`` are only supported.
:type framebuffer: luma.core.framebuffer.framebuffer
:param bgr: Set to ``True`` if device pixels are BGR order (rather than RGB).
:type bgr: bool
:param h_offset: Horizontal offset (in pixels) of screen to device memory
(default: 0).
:type h_offset: int
:param v_offset: Vertical offset (in pixels) of screen to device memory
(default: 0).
:type v_offset: int
.. versionadded:: ???
"""
def __init__(self, serial_interface=None, width=320, height=480, rotate=0,
framebuffer=None, h_offset=0, v_offset=0, bgr=False, **kwargs):
super(ili9486, self).__init__(luma.lcd.const.ili9486, serial_interface, **kwargs)
self.capabilities(width, height, rotate, mode="RGB")
self.init_framebuffer(framebuffer, 25)

if h_offset != 0 or v_offset != 0:
def offset(bbox):
left, top, right, bottom = bbox
return (left + h_offset, top + v_offset, right + h_offset, bottom + v_offset)
self.apply_offsets = offset
else:
self.apply_offsets = lambda bbox: bbox

# Supported modes
supported = (width, height) in [(320, 480)] # full
if not supported:
raise luma.core.error.DeviceDisplayModeError(
"Unsupported display mode: {0} x {1}".format(width, height))

# RGB or BGR order
order = 0x00 if bgr else 0x08

# Initialization sequence, adapted from MIT Licensed
#
# `https://github.com/juj/fbcp-ili9341/blob/master/ili9486.cpp`
#
# and peer files. The sequence targets the ILI9486-based
# Waveshare "Wavepear" 3.5 inch 320x480 LCD (B)
# implementation. Per comments in the above file and issue
# discussion for juj/fbcp-ili9341, the Waveshare
# implementation makes use of 16-bit shift register for the
# SPI interface, leaving the ILI9486 itself in a parallel
# mode.
#
# The result of that implementation is that registers
# effectively become 16-bit quantities. The sequence below
# (and in the display() function) thus ends up padding
# commands and subsequent values. It doesn't seem to have
# affected pixel transfers, though.
#
# A different ILI9486 implementation may NOT need the padding
# zeros. The juj/fbcp-ili9341 code handles that possibility
# via a DISPLAY_SPI_BUS_IS_16BITS_WIDE ifdef.

self.command(0xb0, 0x00, 0x00) # Interface Mode Control
self.command(0x11) # sleep out
sleep(0.150)
self.command(0x3a, 0x00, 0x66) # Interface Pixel Format 6-6-6
self.command(0x21) # Display inversion ON for LCD(B)
self.command(0xc0, 0x00, 0x09, 0x00, 0x09) # Power Control 1
self.command(0xc1, 0x00, 0x41, 0x00, 0x00) # Power Control 2
self.command(0xc2, 0x00, 0x33) # Power Control 3 (for normal mode)
self.command(0xc5, 0x00, 0x00, 0x00, 0x36) # VCOM control

self.command(0x36, 0x00, 0x00 | order) # Memory Access control (MAD), rotations and color order
# self.command(0xb1, 0x00, 0xb0, 0x00, 0xe0) # Frame Rate Control (needed?)

self.command(0xb6, 0x00, 0x00, 0x00, 0x42, 0x00, 59) # Display Function Control

# Initial trials didn't seem to need Positive, Negative, or
# Digital Gamma Control settings.

self.command(0x13) # Normal mode ON
self.command(0x34) # Tearing effect line oFF
self.command(0x38) # Idle mode OFF

self.command(0x11) # sleep out
sleep(0.150)
self.clear()
self.show()

def display(self, image):
"""
Renders a 24-bit RGB image to the ILI9486 LCD display. The 8-bit RGB
values are passed directly to the devices internal storage, but only
the 6 most-significant bits are used by the display.
:param image: The image to render.
:type image: PIL.Image.Image
"""
assert(image.mode == self.mode)
assert(image.size == self.size)

image = self.preprocess(image)

for image, bounding_box in self.framebuffer.redraw(image):
# Transposing the display shifts the dimension measurements
top, left, bottom, right = self.apply_offsets(bounding_box)

# Per earlier comments, Waveshare's display needs padding
# for commands.
self.command(0x2a, 0, top >> 8, 0, top & 0xff, 0, (bottom - 1) >> 8, 0, (bottom - 1) & 0xff) # Set row addr
self.command(0x2b, 0, left >> 8, 0, left & 0xff, 0, (right - 1) >> 8, 0, (right - 1) & 0xff) # Set column addr
self.command(0x2c) # Memory write

self.data(image.tobytes())

def contrast(self, level):
"""
NOT SUPPORTED
:param level: Desired contrast level in the range of 0-255.
:type level: int
"""
assert(0 <= level <= 255)

def command(self, cmd, *args):
"""
Sends a command and an (optional) sequence of arguments through to the
delegated serial interface. Note that the arguments are passed through
as data.
"""
self._serial_interface.command(cmd)
if len(args) > 0:
self._serial_interface.data(list(args))


@rpi_gpio
class ht1621(backlit_device):
"""
Expand Down
1 change: 1 addition & 0 deletions tests/reference/data/demo_ili9486.json

Large diffs are not rendered by default.

159 changes: 159 additions & 0 deletions tests/test_ili9486.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright (c) 2020 Richard Hull and contributors
# See LICENSE.rst for details.

"""
Tests for the :py:class:`luma.lcd.device.ili9486` device.
"""

import pytest

from luma.lcd.device import ili9486
from luma.core.render import canvas
from luma.core.framebuffer import full_frame

from baseline_data import get_reference_data, primitives
from helpers import serial, setup_function, assert_invalid_dimensions # noqa: F401
from unittest.mock import Mock


def test_init_320x480():
recordings = []

def data(data):
recordings.append({'data': data})

def command(*cmd):
recordings.append({'command': list(cmd)})

serial.command.side_effect = command
serial.data.side_effect = data

ili9486(serial, gpio=Mock(), framebuffer=full_frame())

assert serial.data.called
assert serial.command.called

# This set of expected results include the padding bytes that
# appear necessary with Waveshare's ili9486 implementation.
assert recordings == [
{'command': [0xb0]}, {'data': [0x00, 0x00]},
{'command': [0x11]},
{'command': [0x3a]}, {'data': [0x00, 0x66]},
{'command': [0x21]},
{'command': [0xc0]}, {'data': [0x00, 0x09, 0x00, 0x09]},
{'command': [0xc1]}, {'data': [0x00, 0x41, 0x00, 0x00]},
{'command': [0xc2]}, {'data': [0x00, 0x33]},
{'command': [0xc5]}, {'data': [0x00, 0x00, 0x00, 0x36]},
{'command': [0x36]}, {'data': [0x00, 0x08]},
{'command': [0xb6]}, {'data': [0x00, 0x00, 0x00, 0x42, 0x00, 0x3b]},
{'command': [0x13]},
{'command': [0x34]},
{'command': [0x38]},
{'command': [0x11]},
{'command': [0x2a]}, {'data': [0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x3f]},
{'command': [0x2b]}, {'data': [0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0xdf]},
{'command': [0x2c]},
{'data': bytearray([0x00] * (320 * 480 * 3))},
{'command': [0x29]},
]


def test_init_invalid_dimensions():
"""
ILI9486 LCD with an invalid resolution raises a
:py:class:`luma.core.error.DeviceDisplayModeError`.
"""
assert_invalid_dimensions(ili9486, serial, 128, 77)


def test_offsets():
recordings = []

def data(data):
recordings.append({'data': data})

def command(*cmd):
recordings.append({'command': list(cmd)})

serial.command.side_effect = command
serial.data.side_effect = data

ili9486(serial, gpio=Mock(), width=320, height=480, h_offset=2, v_offset=1, framebuffer=full_frame())

assert serial.data.called
assert serial.command.called

assert recordings == [
{'command': [0xb0]}, {'data': [0x00, 0x00]},
{'command': [0x11]},
{'command': [0x3a]}, {'data': [0x00, 0x66]},
{'command': [0x21]},
{'command': [0xc0]}, {'data': [0x00, 0x09, 0x00, 0x09]},
{'command': [0xc1]}, {'data': [0x00, 0x41, 0x00, 0x00]},
{'command': [0xc2]}, {'data': [0x00, 0x33]},
{'command': [0xc5]}, {'data': [0x00, 0x00, 0x00, 0x36]},
{'command': [0x36]}, {'data': [0x00, 0x08]},
{'command': [0xb6]}, {'data': [0x00, 0x00, 0x00, 0x42, 0x00, 0x3b]},
{'command': [0x13]},
{'command': [0x34]},
{'command': [0x38]},
{'command': [0x11]},
{'command': [0x2A]}, {'data': [0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x3f + 0x02]},
{'command': [0x2B]}, {'data': [0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0xdf + 0x01]},
{'command': [0x2C]},
{'data': bytearray([0x00] * (320 * 480 * 3))},
{'command': [0x29]},
]


def test_contrast():
device = ili9486(serial, gpio=Mock())
serial.reset_mock()
with pytest.raises(AssertionError):
device.contrast(300)


def test_hide():
device = ili9486(serial, gpio=Mock())
serial.reset_mock()
device.hide()
serial.command.assert_called_once_with(40)


def test_show():
device = ili9486(serial, gpio=Mock())
serial.reset_mock()
device.show()
serial.command.assert_called_once_with(41)


def test_display_full_frame():
device = ili9486(serial, gpio=Mock(), framebuffer=full_frame())
serial.reset_mock()

recordings = []

def data(data):
recordings.append({'data': list(data)})

def command(*cmd):
recordings.append({'command': list(cmd)})

serial.command.side_effect = command
serial.data.side_effect = data

# Use the same drawing primitives as the demo
with canvas(device) as draw:
primitives(device, draw)

assert serial.data.called
assert serial.command.called

# To regenerate test data, uncomment the following (remember not to commit though)
# ================================================================================
# from baseline_data import save_reference_data
# save_reference_data("demo_ili9486", recordings)

assert recordings == get_reference_data('demo_ili9486')

0 comments on commit 40ebe37

Please sign in to comment.