Skip to content

Commit

Permalink
Merge fd526d0 into 2a076f5
Browse files Browse the repository at this point in the history
  • Loading branch information
rm-hull committed Oct 30, 2020
2 parents 2a076f5 + fd526d0 commit ea257bd
Show file tree
Hide file tree
Showing 11 changed files with 257 additions and 11 deletions.
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ ChangeLog
| Version | Description | Date |
+============+=====================================================================+============+
| **2.0.0** | * Improved diff_to_previous framebuffer performance | TBC |
| | * Add Linux framebuffer pseudo-device | |
| | * Allow a wider range of SPI bus speeds | |
+------------+---------------------------------------------------------------------+------------+
| **1.17.3** | * Drop support for Python 3.5, only 3.6 or newer is supported now | 2020/10/24 |
Expand Down
26 changes: 18 additions & 8 deletions luma/core/cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def get_supported_libraries():
:rtype: list
"""
return ['oled', 'lcd', 'led_matrix', 'emulator']
return ['core', 'oled', 'lcd', 'led_matrix', 'emulator']


def get_library_for_display_type(display_type):
Expand Down Expand Up @@ -126,6 +126,7 @@ class make_interface(object):
"""
Serial factory.
"""

def __init__(self, opts, gpio=None):
self.opts = opts
self.gpio = gpio
Expand Down Expand Up @@ -229,7 +230,12 @@ def create_device(args, display_types=None):
if display_types is None:
display_types = get_display_types()

if args.display in display_types.get('oled', []):
if args.display in display_types.get('core', []):
import luma.core.device
Device = getattr(luma.core.device, args.display)
device = Device(device=args.framebuffer_device, **vars(args))

elif args.display in display_types.get('oled', []):
import luma.oled.device
Device = getattr(luma.oled.device, args.display)
interface = getattr(make_interface(args), args.interface)
Expand Down Expand Up @@ -309,14 +315,18 @@ def create_parser(description):
ftdi_group = parser.add_argument_group('FTDI')
ftdi_group.add_argument('--ftdi-device', type=str, default='ftdi://::/1', help='FTDI device')

linux_framebuffer_group = parser.add_argument_group('Linux framebuffer')
linux_framebuffer_group.add_argument('--framebuffer-device', type=str, default='/dev/fd0', help='Linux framebuffer device')

gpio_group = parser.add_argument_group('GPIO')
gpio_group.add_argument('--gpio', type=str, default=None, help='Alternative RPi.GPIO compatible implementation (SPI devices only)')
gpio_group.add_argument('--gpio-mode', type=str, default=None, help='Alternative pin mapping mode (SPI devices only)')
gpio_group.add_argument('--gpio-data-command', type=int, default=24, help='GPIO pin for D/C RESET (SPI devices only)')
gpio_group.add_argument('--gpio-reset', type=int, default=25, help='GPIO pin for RESET (SPI devices only)')
gpio_group.add_argument('--gpio', type=str, default=None, help='Alternative RPi.GPIO compatible implementation (SPI interface only)')
gpio_group.add_argument('--gpio-mode', type=str, default=None, help='Alternative pin mapping mode (SPI interface only)')
gpio_group.add_argument('--gpio-data-command', type=int, default=24, help='GPIO pin for D/C RESET (SPI interface only)')
gpio_group.add_argument('--gpio-chip-select', type=int, default=24, help='GPIO pin for Chip select (GPIO_CS_SPI interface only)')
gpio_group.add_argument('--gpio-reset', type=int, default=25, help='GPIO pin for RESET (SPI interface only)')
gpio_group.add_argument('--gpio-backlight', type=int, default=18, help='GPIO pin for backlight (PCD8544, ST7735 devices only)')
gpio_group.add_argument('--gpio-reset-hold-time', type=float, default=0, help='Duration to hold reset line active on startup (seconds) (SPI devices only)')
gpio_group.add_argument('--gpio-reset-release-time', type=float, default=0, help='Duration to pause for after reset line was made active on startup (seconds) (SPI devices only)')
gpio_group.add_argument('--gpio-reset-hold-time', type=float, default=0, help='Duration to hold reset line active on startup (seconds) (SPI interface only)')
gpio_group.add_argument('--gpio-reset-release-time', type=float, default=0, help='Duration to pause for after reset line was made active on startup (seconds) (SPI interface only)')

misc_group = parser.add_argument_group('Misc')
misc_group.add_argument('--block-orientation', type=int, default=0, help=f'Fix 90° phase error (MAX7219 LED matrix only). Allowed values are: {block_orientation_choices_repr}', choices=block_orientation_choices, metavar='ORIENTATION')
Expand Down
93 changes: 91 additions & 2 deletions luma/core/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# Copyright (c) 2017-20 Richard Hull and contributors
# See LICENSE.rst for details.

import os
import atexit
from time import sleep

Expand All @@ -10,6 +11,8 @@
import luma.core.const
from luma.core.interface.serial import i2c, noop

__all__ = ["fb"]


class device(mixin.capabilities):
"""
Expand All @@ -21,6 +24,7 @@ class device(mixin.capabilities):
:func:`display` method, or preferably with the
:class:`luma.core.render.canvas` context manager.
"""

def __init__(self, const=None, serial_interface=None):
self._const = const or luma.core.const.common
self._serial_interface = serial_interface or i2c()
Expand Down Expand Up @@ -71,7 +75,7 @@ def contrast(self, level):
:param level: Desired contrast level in the range of 0-255.
:type level: int
"""
assert(0 <= level <= 255)
assert 0 <= level <= 255
self.command(self._const.SETCONTRAST, level)

def cleanup(self):
Expand Down Expand Up @@ -155,6 +159,7 @@ class dummy(device):
other than retain a copy of the displayed image. It is mostly useful for
testing. Supports 24-bit color depth.
"""

def __init__(self, width=128, height=64, rotate=0, mode="RGB", **kwargs):
super(dummy, self).__init__(serial_interface=noop())
self.capabilities(width, height, rotate, mode)
Expand All @@ -168,6 +173,90 @@ def display(self, image):
:param image: Image to display.
:type image: PIL.Image.Image
"""
assert(image.size == self.size)
assert image.size == self.size

self.image = self.preprocess(image).copy()


class fb(device):
"""
Pseudo-device that acts like a physical display, except that it renders
to a Linux framebuffer device at /dev/fbN (where N=0, 1, ...). This is specifically
targetted to allow the luma classes to be used on higher-resolution displays that
leverage kernel-based display drivers.
.. note:
Currently only supports 16-bit and 24-bit RGB color depths.
:param device: the Linux framebuffer device (e.g. `/dev/fb0`). If no device
is given, the device is determined from the `FRAMEBUFFER` environmental
variable instead. See https://www.kernel.org/doc/html/latest/fb/framebuffer.html
for more details.
.. versionadded:: 2.0.0
"""

def __init__(self, device=None, **kwargs):
super(fb, self).__init__(serial_interface=noop())
self.id = self.__get_display_id(device)
(width, height) = self.__config("virtual_size")
self.bits_per_pixel = next(self.__config("bits_per_pixel"))
image_converters = {
16: self.__toRGB565,
24: self.__toRGB,
}
assert self.bits_per_pixel in image_converters, f"Unsupported bit-depth: {self.bits_per_pixel}"
self.__image_converter = image_converters[self.bits_per_pixel]

self.capabilities(width, height, rotate=0, mode="RGB")

def __get_display_id(self, device):
"""
Extract the display-id from the device which is usually referred in
the form `/dev/fbN` where N is numeric. If no device is given, defer
to the FRAMEBUFFER environmental variable.
See https://www.kernel.org/doc/html/latest/fb/framebuffer.html for more details.
"""
if device is None:
device = os.environ.get("FRAMEBUFFER", "/dev/fb0")

if device.startswith("/dev/fb"):
return int(device[7:])

raise luma.core.error.DeviceNotFoundError(
"Invalid/unsupported framebuffer: {}".format(device)
)

def __config(self, section):
path = "/sys/class/graphics/fb{0}/{1}".format(self.id, section)
with open(path, "r") as fp:
for value in fp.read().strip().split(","):
if value:
yield int(value)

def __toRGB565(self, image):
for r, g, b in image.getdata():
yield g << 3 & 0xE0 | b >> 3
yield r & 0xF8 | g >> 5

def __toRGB(self, image):
return image.tobytes()

def display(self, image):
"""
Takes a :py:mod:`PIL.Image` and converts it for consumption on the
given /dev/fbX framebuffer device.
:param image: Image to display.
:type image: PIL.Image.Image
"""
assert image.mode == self.mode
assert image.size == self.size

image = self.preprocess(image)
path = "/dev/fb{}".format(self.id)
data = bytes(self.__image_converter(image))

with open(path, "wb") as fp:
fp.write(data)
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ def find_version(*file_paths):
test_deps = [
"pytest",
"pytest-cov",
"pytest-timeout"
"pytest-timeout",
"pytest-watch"
]

install_deps = [
Expand Down
18 changes: 18 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import pytest
from PIL import ImageChops, ImageFont
from unittest.mock import mock_open


rpi_gpio_missing = f'RPi.GPIO is not supported on this platform: {platform.system()}'
Expand Down Expand Up @@ -91,5 +92,22 @@ def fib(n):
a, b = b, a + b


# Attribution: https://gist.github.com/adammartinez271828/137ae25d0b817da2509c1a96ba37fc56
def multi_mock_open(*file_contents):
"""Create a mock "open" that will mock open multiple files in sequence
Args:
*file_contents ([str]): a list of file contents to be returned by open
Returns:
(MagicMock) a mock opener that will return the contents of the first
file when opened the first time, the second file when opened the
second time, etc.
"""
mock_files = [mock_open(read_data=content) for content in file_contents]
mock_opener = mock_files[-1]
mock_opener.side_effect = [mock_file.return_value for mock_file in mock_files]

return mock_opener


def skip_unsupported_platform(err):
pytest.skip(f'{type(err).__name__} ({str(err)})')
Binary file added tests/reference/fb_16bpp.raw
Binary file not shown.
Binary file added tests/reference/fb_24bpp.raw
Binary file not shown.
23 changes: 23 additions & 0 deletions tests/test_cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class test_spi_opts(object):
framebuffer = 'diff_to_previous'
num_segments = 25
debug = False
framebuffer_device = None


def test_get_interface_types():
Expand Down Expand Up @@ -376,6 +377,28 @@ class args(test_spi_opts):
assert device == display_name


def test_create_device_core():
"""
:py:func:`luma.core.cmdline.create_device` supports code devices.
"""
display_name = 'coredevice1234'
display_types = {'core': [display_name]}

class args(test_spi_opts):
display = display_name

module_mock = Mock()
module_mock.core.device.coredevice1234.return_value = display_name
with patch.dict('sys.modules', **{
# mock luma.core package
'luma': module_mock,
'luma.core': module_mock,
'luma.core.device': module_mock
}):
device = cmdline.create_device(args, display_types=display_types)
assert device == display_name


@patch('pyftdi.spi.SpiController')
def test_make_interface_ftdi_spi(mock_controller):
"""
Expand Down
File renamed without changes.
100 changes: 100 additions & 0 deletions tests/test_fb_device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/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.core.device.framebuffer` class.
"""
import os
import pytest

from luma.core.render import canvas
from luma.core.device import fb
import luma.core.error

from helpers import multi_mock_open
from unittest.mock import patch, call

SCREEN_RES = "124,55"
BITS_PER_PIXEL = "24"


def test_display_id_as_dev_fb_number():
with patch("builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL)):
device = fb("/dev/fb9")
assert device.id == 9


def test_display_id_from_environ():
os.environ["FRAMEBUFFER"] = "/dev/fb16"
with patch("builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL)):
device = fb()
assert device.id == 16


def test_unknown_display_id():
with patch("builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL)):
with pytest.raises(luma.core.error.DeviceNotFoundError):
fb("invalid fb")


def test_read_screen_resolution():
with patch(
"builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL)
) as fake_open:
device = fb("/dev/fb1")
assert device.width == 124
assert device.height == 55
fake_open.assert_has_calls([call("/sys/class/graphics/fb1/virtual_size", "r")])


def test_read_bits_per_pixel():
with patch(
"builtins.open", multi_mock_open(SCREEN_RES, BITS_PER_PIXEL)
) as fake_open:
device = fb("/dev/fb1")
assert device.bits_per_pixel == 24
fake_open.assert_has_calls(
[call("/sys/class/graphics/fb1/bits_per_pixel", "r")]
)


def test_display_16bpp():
with open("tests/reference/fb_16bpp.raw", "rb") as fp:
reference = fp.read()

with patch("builtins.open", multi_mock_open(SCREEN_RES, "16", None)) as fake_open:
device = fb("/dev/fb1")
with canvas(device, dither=True) as draw:
draw.rectangle((0, 0, 64, 32), fill="red")
draw.rectangle((64, 0, 128, 32), fill="yellow")
draw.rectangle((0, 32, 64, 64), fill="orange")
draw.rectangle((64, 32, 128, 64), fill="white")

fake_open.assert_has_calls([call("/dev/fb1", "wb")])
fake_open.return_value.write.assert_called_once_with(reference)


def test_display_24bpp():
with open("tests/reference/fb_24bpp.raw", "rb") as fp:
reference = fp.read()

with patch("builtins.open", multi_mock_open(SCREEN_RES, "24", None)) as fake_open:
device = fb("/dev/fb1")
with canvas(device, dither=True) as draw:
draw.rectangle((0, 0, 64, 32), fill="red")
draw.rectangle((64, 0, 128, 32), fill="yellow")
draw.rectangle((0, 32, 64, 64), fill="orange")
draw.rectangle((64, 32, 128, 64), fill="white")

fake_open.assert_has_calls([call("/dev/fb1", "wb")])
fake_open.return_value.write.assert_called_once_with(reference)


def test_unsupported_bit_depth():

with patch("builtins.open", multi_mock_open(SCREEN_RES, "32", None)):
with pytest.raises(AssertionError) as ex:
fb("/dev/fb4")
assert str(ex.value) == 'Unsupported bit-depth: 32'
4 changes: 4 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ commands =
coverage html
deps = .[test]

[testenv:watch] # use ptw (=pytestwatch) to run tests when files change
commands =
ptw -v {posargs}

[testenv:qa]
commands =
flake8
Expand Down

0 comments on commit ea257bd

Please sign in to comment.