import time
import struct
-import threading
from typing import Optional
-from dataclasses import astuple
import digitalio
from PIL import Image
-import numpy
import microcontroller
-import _typing
+import circuitpython_typing
from ._displaycore import _DisplayCore
from ._displaybus import _DisplayBus
from ._colorconverter import ColorConverter
from ._group import Group
from ._structs import RectangleStruct
+from ._area import Area
from ._constants import (
CHIP_SELECT_TOGGLE_EVERY_BYTE,
CHIP_SELECT_UNTOUCHED,
DISPLAY_DATA,
BACKLIGHT_IN_OUT,
BACKLIGHT_PWM,
+ NO_COMMAND,
)
-__version__ = "0.0.0-auto.0"
+__version__ = "0.0.0+auto.0"
__repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
-displays = []
-
class Display:
# pylint: disable=too-many-instance-attributes
def __init__(
self,
display_bus: _DisplayBus,
- init_sequence: _typing.ReadableBuffer,
+ init_sequence: circuitpython_typing.ReadableBuffer,
*,
width: int,
height: int,
The initialization sequence should always leave the display memory access inline with
the scan of the display to minimize tearing artifacts.
"""
+ # Turn off auto-refresh as we init
+ self._auto_refresh = False
ram_width = 0x100
ram_height = 0x100
if single_byte_bounds:
ram_width = 0xFF
ram_height = 0xFF
+
self._core = _DisplayCore(
bus=display_bus,
width=width,
bytes_per_cell=bytes_per_cell,
reverse_pixels_in_byte=reverse_pixels_in_byte,
reverse_bytes_in_word=reverse_bytes_in_word,
+ column_command=set_column_command,
+ row_command=set_row_command,
+ set_current_column_command=NO_COMMAND,
+ set_current_row_command=NO_COMMAND,
+ data_as_commands=data_as_commands,
+ always_toggle_chip_select=False,
+ sh1107_addressing=(SH1107_addressing and color_depth == 1),
+ address_little_endian=False,
)
- self._set_column_command = set_column_command
- self._set_row_command = set_row_command
self._write_ram_command = write_ram_command
self._brightness_command = brightness_command
- self._data_as_commands = data_as_commands
- self._single_byte_bounds = single_byte_bounds
- self._width = width
- self._height = height
- self._colstart = colstart
- self._rowstart = rowstart
- self._rotation = rotation
+ self._first_manual_refresh = not auto_refresh
+ self._backlight_on_high = backlight_on_high
+
+ self._native_frames_per_second = native_frames_per_second
+ self._native_ms_per_frame = 1000 // native_frames_per_second
+
self._auto_brightness = auto_brightness
- self._brightness = 1.0
+ self._brightness = brightness
self._auto_refresh = auto_refresh
+
self._initialize(init_sequence)
self._buffer = Image.new("RGB", (width, height))
- self._subrectangles = []
- self._bounds_encoding = ">BB" if single_byte_bounds else ">HH"
self._current_group = None
- displays.append(self)
+ self._last_refresh_call = 0
self._refresh_thread = None
if self._auto_refresh:
self.auto_refresh = True
self._backlight.switch_to_output()
self.brightness = brightness
+ def __new__(cls, *args, **kwargs):
+ from . import ( # pylint: disable=import-outside-toplevel, cyclic-import
+ allocate_display,
+ )
+
+ display_instance = super().__new__(cls)
+ allocate_display(display_instance)
+ return display_instance
+
def _initialize(self, init_sequence):
i = 0
while i < len(init_sequence):
delay = (data_size & 0x80) > 0
data_size &= ~0x80
- if self._data_as_commands:
+ if self._core.data_as_commands:
self._core.send(
DISPLAY_COMMAND,
CHIP_SELECT_TOGGLE_EVERY_BYTE,
time.sleep(delay_time_ms / 1000)
i += 2 + data_size
- def _send(self, command, data):
- self._core.begin_transaction()
- if self._data_as_commands:
- self._core.send(
- DISPLAY_COMMAND, CHIP_SELECT_TOGGLE_EVERY_BYTE, bytes([command]) + data
- )
- else:
- self._core.send(
- DISPLAY_COMMAND, CHIP_SELECT_TOGGLE_EVERY_BYTE, bytes([command])
- )
- self._core.send(DISPLAY_DATA, CHIP_SELECT_UNTOUCHED, data)
- self._core.end_transaction()
-
- def _send_pixels(self, data):
- if not self._data_as_commands:
+ def _send_pixels(self, pixels):
+ if not self._core.data_as_commands:
self._core.send(
DISPLAY_COMMAND,
CHIP_SELECT_TOGGLE_EVERY_BYTE,
bytes([self._write_ram_command]),
)
- self._core.send(DISPLAY_DATA, CHIP_SELECT_UNTOUCHED, data)
+ self._core.send(DISPLAY_DATA, CHIP_SELECT_UNTOUCHED, pixels)
def show(self, group: Group) -> None:
"""Switches to displaying the given group of layers. When group is None, the
target_frames_per_second: Optional[int] = None,
minimum_frames_per_second: int = 0,
) -> bool:
- # pylint: disable=unused-argument, protected-access
"""When auto refresh is off, waits for the target frame rate and then refreshes the
display, returning True. If the call has taken too long since the last refresh call
for the given target frame rate, then the refresh returns False immediately without
When auto refresh is on, updates the display immediately. (The display will also
update without calls to this.)
"""
+ maximum_ms_per_real_frame = 0xFFFFFFFF
+ if minimum_frames_per_second > 0:
+ maximum_ms_per_real_frame = 1000 // minimum_frames_per_second
+
+ if target_frames_per_second is None:
+ target_ms_per_frame = 0xFFFFFFFF
+ else:
+ target_ms_per_frame = 1000 // target_frames_per_second
+
+ if (
+ not self._auto_refresh
+ and not self._first_manual_refresh
+ and target_ms_per_frame != 0xFFFFFFFF
+ ):
+ current_time = time.monotonic() * 1000
+ current_ms_since_real_refresh = current_time - self._core.last_refresh
+ if current_ms_since_real_refresh > maximum_ms_per_real_frame:
+ raise RuntimeError("Below minimum frame rate")
+ current_ms_since_last_call = current_time - self._last_refresh_call
+ self._last_refresh_call = current_time
+ if current_ms_since_last_call > target_ms_per_frame:
+ return False
+
+ remaining_time = target_ms_per_frame - (
+ current_ms_since_real_refresh % target_ms_per_frame
+ )
+ time.sleep(remaining_time / 1000)
+ self._first_manual_refresh = False
+ self._refresh_display()
+ return True
+
+ def _refresh_display(self):
if not self._core.start_refresh():
return False
+ # TODO: Likely move this to _refresh_area()
# Go through groups and and add each to buffer
- if self._core._current_group is not None:
- buffer = Image.new("RGBA", (self._core._width, self._core._height))
+ """
+ if self._core.current_group is not None:
+ buffer = Image.new("RGBA", (self._core.width, self._core.height))
# Recursively have everything draw to the image
- self._core._current_group._fill_area(
+ self._core.current_group._fill_area(
buffer
) # pylint: disable=protected-access
# save image to buffer (or probably refresh buffer so we can compare)
self._buffer.paste(buffer)
+ """
+ areas_to_refresh = self._get_refresh_areas()
- self._subrectangles = self._core.get_refresh_areas()
-
- for area in self._subrectangles:
- self._refresh_display_area(area)
+ for area in areas_to_refresh:
+ self._refresh_area(area)
self._core.finish_refresh()
return True
- def _refresh_loop(self):
- while self._auto_refresh:
+ def _get_refresh_areas(self) -> list[Area]:
+ """Get a list of areas to be refreshed"""
+ areas = []
+ if self._core.full_refresh:
+ areas.append(self._core.area)
+ elif self._core.current_group is not None:
+ self._core.current_group._get_refresh_areas( # pylint: disable=protected-access
+ areas
+ )
+ return areas
+
+ def background(self):
+ """Run background refresh tasks. Do not call directly"""
+ if (
+ self._auto_refresh
+ and (time.monotonic() * 1000 - self._core.last_refresh)
+ > self._native_ms_per_frame
+ ):
self.refresh()
- def _refresh_display_area(self, rectangle):
- """Loop through dirty rectangles and redraw that area."""
- img = self._buffer.convert("RGB").crop(astuple(rectangle))
- img = img.rotate(self._rotation, expand=True)
-
- display_rectangle = self._apply_rotation(rectangle)
- img = img.crop(astuple(self._clip(display_rectangle)))
-
- data = numpy.array(img).astype("uint16")
- color = (
- ((data[:, :, 0] & 0xF8) << 8)
- | ((data[:, :, 1] & 0xFC) << 3)
- | (data[:, :, 2] >> 3)
- )
-
- pixels = bytes(
- numpy.dstack(((color >> 8) & 0xFF, color & 0xFF)).flatten().tolist()
- )
-
- self._send(
- self._set_column_command,
- self._encode_pos(
- display_rectangle.x1 + self._colstart,
- display_rectangle.x2 + self._colstart - 1,
- ),
- )
- self._send(
- self._set_row_command,
- self._encode_pos(
- display_rectangle.y1 + self._rowstart,
- display_rectangle.y2 + self._rowstart - 1,
- ),
- )
-
- self._core.begin_transaction()
- self._send_pixels(pixels)
- self._core.end_transaction()
-
- def _clip(self, rectangle):
- if self._rotation in (90, 270):
- width, height = self._height, self._width
- else:
- width, height = self._width, self._height
+ def _refresh_area(self, area) -> bool:
+ """Loop through dirty areas and redraw that area."""
+ # pylint: disable=too-many-locals
+ buffer_size = 128
+
+ clipped = Area()
+ if not self._core.clip_area(area, clipped):
+ return True
+
+ rows_per_buffer = clipped.height()
+ pixels_per_word = (struct.calcsize("I") * 8) // self._core.colorspace.depth
+ pixels_per_buffer = clipped.size()
+
+ subrectangles = 1
+
+ if self._core.sh1107_addressing:
+ subrectangles = rows_per_buffer // 8
+ rows_per_buffer = 8
+ elif clipped.size() > buffer_size * pixels_per_word:
+ rows_per_buffer = buffer_size * pixels_per_word // clipped.width()
+ if rows_per_buffer == 0:
+ rows_per_buffer = 1
+ if (
+ self._core.colorspace.depth < 8
+ and self._core.colorspace.pixels_in_byte_share_row
+ ):
+ pixels_per_byte = 8 // self._core.colorspace.depth
+ if rows_per_buffer % pixels_per_byte != 0:
+ rows_per_buffer -= rows_per_buffer % pixels_per_byte
+ subrectangles = clipped.height() // rows_per_buffer
+ if clipped.height() % rows_per_buffer != 0:
+ subrectangles += 1
+ pixels_per_buffer = rows_per_buffer * clipped.width()
+ buffer_size = pixels_per_buffer // pixels_per_word
+ if pixels_per_buffer % pixels_per_word:
+ buffer_size += 1
+
+ buffer = bytearray(buffer_size)
+ mask_length = (pixels_per_buffer // 32) + 1
+ mask = bytearray(mask_length)
+ remaining_rows = clipped.height()
+
+ for subrect_index in range(subrectangles):
+ subrectangle = Area(
+ clipped.x1,
+ clipped.y1 + rows_per_buffer * subrect_index,
+ clipped.x2,
+ clipped.y1 + rows_per_buffer * (subrect_index + 1),
+ )
+ if remaining_rows < rows_per_buffer:
+ subrectangle.y2 = subrectangle.y1 + remaining_rows
+ self._core.set_region_to_update(subrectangle)
+ if self._core.colorspace.depth >= 8:
+ subrectangle_size_bytes = subrectangle.size() * (
+ self._core.colorspace.depth // 8
+ )
+ else:
+ subrectangle_size_bytes = subrectangle.size() // (
+ 8 // self._core.colorspace.depth
+ )
- rectangle.x1 = max(rectangle.x1, 0)
- rectangle.y1 = max(rectangle.y1, 0)
- rectangle.x2 = min(rectangle.x2, width)
- rectangle.y2 = min(rectangle.y2, height)
+ self._core.fill_area(subrectangle, mask, buffer)
- return rectangle
+ self._core.begin_transaction()
+ self._send_pixels(buffer[:subrectangle_size_bytes])
+ self._core.end_transaction()
+ return True
def _apply_rotation(self, rectangle):
"""Adjust the rectangle coordinates based on rotation"""
- if self._rotation == 90:
+ if self._core.rotation == 90:
return RectangleStruct(
- self._height - rectangle.y2,
+ self._core.height - rectangle.y2,
rectangle.x1,
- self._height - rectangle.y1,
+ self._core.height - rectangle.y1,
rectangle.x2,
)
- if self._rotation == 180:
+ if self._core.rotation == 180:
return RectangleStruct(
- self._width - rectangle.x2,
- self._height - rectangle.y2,
- self._width - rectangle.x1,
- self._height - rectangle.y1,
+ self._core.width - rectangle.x2,
+ self._core.height - rectangle.y2,
+ self._core.width - rectangle.x1,
+ self._core.height - rectangle.y1,
)
- if self._rotation == 270:
+ if self._core.rotation == 270:
return RectangleStruct(
rectangle.y1,
- self._width - rectangle.x2,
+ self._core.width - rectangle.x2,
rectangle.y2,
- self._width - rectangle.x1,
+ self._core.width - rectangle.x1,
)
return rectangle
- def _encode_pos(self, x, y):
- """Encode a postion into bytes."""
- return struct.pack(self._bounds_encoding, x, y) # pylint: disable=no-member
-
def fill_row(
- self, y: int, buffer: _typing.WriteableBuffer
- ) -> _typing.WriteableBuffer:
+ self, y: int, buffer: circuitpython_typing.WriteableBuffer
+ ) -> circuitpython_typing.WriteableBuffer:
"""Extract the pixels from a single row"""
- for x in range(0, self._width):
+ for x in range(0, self._core.width):
_rgb_565 = self._colorconverter.convert(self._buffer.getpixel((x, y)))
buffer[x * 2] = (_rgb_565 >> 8) & 0xFF
buffer[x * 2 + 1] = _rgb_565 & 0xFF
return buffer
+ def release(self) -> None:
+ """Release the display and free its resources"""
+ self.auto_refresh = False
+ self._core.release_display_core()
+
+ def reset(self) -> None:
+ """Reset the display"""
+ self.auto_refresh = True
+
@property
def auto_refresh(self) -> bool:
"""True when the display is refreshed automatically."""
@auto_refresh.setter
def auto_refresh(self, value: bool):
+ self._first_manual_refresh = not value
self._auto_refresh = value
- if self._refresh_thread is None:
- self._refresh_thread = threading.Thread(
- target=self._refresh_loop, daemon=True
- )
- if value and not self._refresh_thread.is_alive():
- # Start the thread
- self._refresh_thread.start()
- elif not value and self._refresh_thread.is_alive():
- # Stop the thread
- self._refresh_thread.join()
@property
def brightness(self) -> float:
@brightness.setter
def brightness(self, value: float):
if 0 <= float(value) <= 1.0:
- self._brightness = value
- if self._backlight_type == BACKLIGHT_IN_OUT:
- self._backlight.value = round(self._brightness)
- elif self._backlight_type == BACKLIGHT_PWM:
- self._backlight.duty_cycle = self._brightness * 65535
+ if not self._backlight_on_high:
+ value = 1.0 - value
+
+ if self._backlight_type == BACKLIGHT_PWM:
+ self._backlight.duty_cycle = value * 0xFFFF
+ elif self._backlight_type == BACKLIGHT_IN_OUT:
+ self._backlight.value = value > 0.99
elif self._brightness_command is not None:
- self._send(self._brightness_command, round(value * 255))
+ self._core.begin_transaction()
+ if self._core.data_as_commands:
+ self._core.send(
+ DISPLAY_COMMAND,
+ CHIP_SELECT_TOGGLE_EVERY_BYTE,
+ bytes([self._brightness_command, 0xFF * value]),
+ )
+ else:
+ self._core.send(
+ DISPLAY_COMMAND,
+ CHIP_SELECT_TOGGLE_EVERY_BYTE,
+ bytes([self._brightness_command]),
+ )
+ self._core.send(
+ DISPLAY_DATA, CHIP_SELECT_UNTOUCHED, round(value * 255)
+ )
+ self._core.end_transaction()
+ self._brightness = value
else:
raise ValueError("Brightness must be between 0.0 and 1.0")