From 622133713388c31604224bd5b5b758a28b3cf4b0 Mon Sep 17 00:00:00 2001 From: Melissa LeBlanc-Williams Date: Wed, 20 Sep 2023 19:12:22 -0700 Subject: [PATCH] Keep track of displays and buses in init --- displayio/__init__.py | 56 ++++++++++- displayio/_constants.py | 3 + displayio/_display.py | 152 ++++++++++++++++++----------- displayio/_displaycore.py | 196 +++++++++++++++++++++----------------- displayio/_i2cdisplay.py | 8 ++ terminalio.py | 3 +- 6 files changed, 268 insertions(+), 150 deletions(-) diff --git a/displayio/__init__.py b/displayio/__init__.py index a908e1a..f357dfa 100644 --- a/displayio/__init__.py +++ b/displayio/__init__.py @@ -16,7 +16,7 @@ displayio for Blinka * Author(s): Melissa LeBlanc-Williams """ - +import threading from typing import Union from ._fourwire import FourWire from ._i2cdisplay import I2CDisplay @@ -30,19 +30,67 @@ from ._ondiskbitmap import OnDiskBitmap from ._palette import Palette from ._shape import Shape from ._tilegrid import TileGrid -from ._display import displays from ._displaybus import _DisplayBus +from ._constants import CIRCUITPY_DISPLAY_LIMIT __version__ = "0.0.0+auto.0" __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git" +displays = [] +display_buses = [] + + +def _background(): + """Main thread function to loop through all displays and update them""" + for display in displays: + display._background() # pylint: disable=protected-access + + def release_displays() -> None: """Releases any actively used displays so their busses and pins can be used again. Use this once in your code.py if you initialize a display. Place it right before the initialization so the display is active as long as possible. """ - for _disp in displays: - _disp._release() # pylint: disable=protected-access + for display in displays: + display._release() # pylint: disable=protected-access displays.clear() + + for display_bus in display_buses: + display_bus._release() # pylint: disable=protected-access + display_buses.clear() + + +def allocate_display(new_display: Union[Display, EPaperDisplay]) -> None: + """Add a display to the displays pool and return the new display""" + if len(displays) >= CIRCUITPY_DISPLAY_LIMIT: + raise RuntimeError("Too many displays") + displays.append(new_display) + + +def allocate_display_bus(new_display_bus: _DisplayBus) -> None: + """Add a display bus to the display_buses pool and return the new display bus""" + if len(display_buses) >= CIRCUITPY_DISPLAY_LIMIT: + raise RuntimeError( + "Too many display busses; forgot displayio.release_displays() ?" + ) + display_buses.append(new_display_bus) + + +background_thread = threading.Thread(target=_background, daemon=True) + + +# Start the background thread +def _start_background(): + if not background_thread.is_alive(): + background_thread.start() + + +def _stop_background(): + if background_thread.is_alive(): + # Stop the thread + background_thread.join() + + +_start_background() diff --git a/displayio/_constants.py b/displayio/_constants.py index 938b49c..d255dbf 100644 --- a/displayio/_constants.py +++ b/displayio/_constants.py @@ -18,3 +18,6 @@ CHIP_SELECT_TOGGLE_EVERY_BYTE = 1 BACKLIGHT_IN_OUT = 1 BACKLIGHT_PWM = 2 + +NO_COMMAND = 0x100 +CIRCUITPY_DISPLAY_LIMIT = 1 diff --git a/displayio/_display.py b/displayio/_display.py index 241a0aa..eb3655d 100644 --- a/displayio/_display.py +++ b/displayio/_display.py @@ -19,7 +19,6 @@ displayio for Blinka import time import struct -import threading from typing import Optional from dataclasses import astuple import digitalio @@ -39,13 +38,12 @@ from ._constants import ( DISPLAY_DATA, BACKLIGHT_IN_OUT, BACKLIGHT_PWM, + NO_COMMAND, ) __version__ = "0.0.0+auto.0" __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git" -displays = [] - class Display: # pylint: disable=too-many-instance-attributes @@ -118,11 +116,14 @@ class Display: 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, @@ -138,28 +139,34 @@ class Display: 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 @@ -182,6 +189,14 @@ class Display: self._backlight.switch_to_output() self.brightness = brightness + def __new__(cls, *args, **kwargs): + from . import ( # pylint: disable=import-outside-toplevel, cyclic-import + allocate_display, + ) + + allocate_display(cls) + return super().__new__(cls) + def _initialize(self, init_sequence): i = 0 while i < len(init_sequence): @@ -190,7 +205,7 @@ class Display: 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, @@ -216,7 +231,7 @@ class Display: def _send(self, command, data): self._core.begin_transaction() - if self._data_as_commands: + if self._core.data_as_commands: self._core.send( DISPLAY_COMMAND, CHIP_SELECT_TOGGLE_EVERY_BYTE, bytes([command]) + data ) @@ -228,7 +243,7 @@ class Display: self._core.end_transaction() def _send_pixels(self, data): - if not self._data_as_commands: + if not self._core.data_as_commands: self._core.send( DISPLAY_COMMAND, CHIP_SELECT_TOGGLE_EVERY_BYTE, @@ -248,7 +263,6 @@ class Display: 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 @@ -260,14 +274,47 @@ class Display: 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): + # pylint: disable=protected-access if not self._core.start_refresh(): return False # 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) @@ -282,14 +329,18 @@ class Display: return True - def _refresh_loop(self): - while self._auto_refresh: + def _background(self): + 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(360 - self._rotation, expand=True) + img = img.rotate(360 - self._core.rotation, expand=True) display_rectangle = self._apply_rotation(rectangle) img = img.crop(astuple(self._clip(display_rectangle))) @@ -306,17 +357,17 @@ class Display: ) self._send( - self._set_column_command, + self._core.column_command, self._encode_pos( - display_rectangle.x1 + self._colstart, - display_rectangle.x2 + self._colstart - 1, + display_rectangle.x1 + self._core.colstart, + display_rectangle.x2 + self._core.colstart - 1, ), ) self._send( - self._set_row_command, + self._core.row_command, self._encode_pos( - display_rectangle.y1 + self._rowstart, - display_rectangle.y2 + self._rowstart - 1, + display_rectangle.y1 + self._core.rowstart, + display_rectangle.y2 + self._core.rowstart - 1, ), ) @@ -325,10 +376,10 @@ class Display: self._core.end_transaction() def _clip(self, rectangle): - if self._rotation in (90, 270): - width, height = self._height, self._width + if self._core.rotation in (90, 270): + width, height = self._core.height, self._core.width else: - width, height = self._width, self._height + width, height = self._core.width, self._core.height rectangle.x1 = max(rectangle.x1, 0) rectangle.y1 = max(rectangle.y1, 0) @@ -339,26 +390,26 @@ class Display: 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 @@ -370,7 +421,7 @@ class Display: 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 @@ -384,17 +435,6 @@ class Display: @auto_refresh.setter def auto_refresh(self, value: bool): 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() - self._refresh_thread = None @property def brightness(self) -> float: diff --git a/displayio/_displaycore.py b/displayio/_displaycore.py index a50d094..ccda898 100644 --- a/displayio/_displaycore.py +++ b/displayio/_displaycore.py @@ -22,7 +22,7 @@ __version__ = "0.0.0+auto.0" __repo__ = "https://github.com/adafruit/Adafruit_Blinka_Displayio.git" -from typing import Union +import time import circuitpython_typing from paralleldisplay import ParallelBus from ._fourwire import FourWire @@ -30,12 +30,11 @@ from ._group import Group from ._i2cdisplay import I2CDisplay from ._structs import ColorspaceStruct, TransformStruct, RectangleStruct from ._area import Area - -displays = [] +from ._displaybus import _DisplayBus class _DisplayCore: - # pylint: disable=too-many-arguments, too-many-instance-attributes + # pylint: disable=too-many-arguments, too-many-instance-attributes, too-many-locals def __init__( self, @@ -53,8 +52,16 @@ class _DisplayCore: bytes_per_cell: int, reverse_pixels_in_byte: bool, reverse_bytes_in_word: bool, + column_command: int, + row_command: int, + set_current_column_command: int, + set_current_row_command: int, + data_as_commands: bool, + always_toggle_chip_select: bool, + sh1107_addressing: bool, + address_little_endian: bool, ): - self._colorspace = ColorspaceStruct( + self.colorspace = ColorspaceStruct( depth=color_depth, grayscale=grayscale, grayscale_bit=8 - color_depth, @@ -64,12 +71,23 @@ class _DisplayCore: reverse_bytes_in_word=reverse_bytes_in_word, dither=False, ) - self._current_group = None - self._colstart = colstart - self._rowstart = rowstart - self._last_refresh = 0 - self._refresh_in_progress = False - self._full_refresh = False + self.current_group = None + self.colstart = colstart + self.rowstart = rowstart + self.last_refresh = 0 + + self.column_command = column_command + self.row_command = row_command + self.set_current_column_command = set_current_column_command + self.set_current_row_command = set_current_row_command + self.data_as_commands = data_as_commands + self.always_toggle_chip_select = always_toggle_chip_select + self.sh1107_addressing = sh1107_addressing + self.address_little_endian = address_little_endian + + self.refresh_in_progress = False + self.full_refresh = False + self.last_refresh = 0 if bus: if isinstance(bus, (FourWire, I2CDisplay, ParallelBus)): @@ -81,75 +99,75 @@ class _DisplayCore: raise ValueError("Unsupported display bus type") self._bus = bus - self._area = Area(0, 0, width, height) + self.area = Area(0, 0, width, height) - self._width = width - self._height = height - self._ram_width = ram_width - self._ram_height = ram_height - self._rotation = rotation - self._transform = TransformStruct() + self.width = width + self.height = height + self.ram_width = ram_width + self.ram_height = ram_height + self.rotation = rotation + self.transform = TransformStruct() def set_rotation(self, rotation: int) -> None: """ Sets the rotation of the display as an int in degrees. """ # pylint: disable=protected-access, too-many-branches - transposed = self._rotation in (90, 270) + transposed = self.rotation in (90, 270) will_be_transposed = rotation in (90, 270) if transposed != will_be_transposed: - self._width, self._height = self._height, self._width + self.width, self.height = self.height, self.width - height = self._height - width = self._width + height = self.height + width = self.width rotation %= 360 - self._rotation = rotation - self._transform.x = 0 - self._transform.y = 0 - self._transform.scale = 1 - self._transform.mirror_x = False - self._transform.mirror_y = False - self._transform.transpose_xy = False + self.rotation = rotation + self.transform.x = 0 + self.transform.y = 0 + self.transform.scale = 1 + self.transform.mirror_x = False + self.transform.mirror_y = False + self.transform.transpose_xy = False if rotation in (0, 180): if rotation == 180: - self._transform.mirror_x = True - self._transform.mirror_y = True + self.transform.mirror_x = True + self.transform.mirror_y = True else: - self._transform.transpose_xy = True + self.transform.transpose_xy = True if rotation == 270: - self._transform.mirror_y = True + self.transform.mirror_y = True else: - self._transform.mirror_x = True - - self._area.x1 = 0 - self._area.y1 = 0 - self._area.next = None - - self._transform.dx = 1 - self._transform.dy = 1 - if self._transform.transpose_xy: - self._area.x2 = height - self._area.y2 = width - if self._transform.mirror_x: - self._transform.x = height - self._transform.dx = -1 - if self._transform.mirror_y: - self._transform.y = width - self._transform.dy = -1 + self.transform.mirror_x = True + + self.area.x1 = 0 + self.area.y1 = 0 + self.area.next = None + + self.transform.dx = 1 + self.transform.dy = 1 + if self.transform.transpose_xy: + self.area.x2 = height + self.area.y2 = width + if self.transform.mirror_x: + self.transform.x = height + self.transform.dx = -1 + if self.transform.mirror_y: + self.transform.y = width + self.transform.dy = -1 else: - self._area.x2 = width - self._area.y2 = height - if self._transform.mirror_x: - self._transform.x = width - self._transform.dx = -1 - if self._transform.mirror_y: - self._transform.y = height - self._transform.dy = -1 - - if self._current_group is not None: - self._current_group._update_transform(self._transform) + self.area.x2 = width + self.area.y2 = height + if self.transform.mirror_x: + self.transform.x = width + self.transform.dx = -1 + if self.transform.mirror_y: + self.transform.y = height + self.transform.dy = -1 + + if self.current_group is not None: + self.current_group._update_transform(self.transform) def show(self, root_group: Group) -> bool: # pylint: disable=protected-access @@ -167,25 +185,25 @@ class _DisplayCore: circuitpython_splash = _Supervisor().circuitpython_splash if not circuitpython_splash._in_group: root_group = circuitpython_splash - elif self._current_group == circuitpython_splash: + elif self.current_group == circuitpython_splash: return True """ - if root_group == self._current_group: + if root_group == self.current_group: return True if root_group is not None and root_group._in_group: return False - if self._current_group is not None: - self._current_group._in_group = False + if self.current_group is not None: + self.current_group._in_group = False if root_group is not None: - root_group._update_transform(self._transform) + root_group._update_transform(self.transform) root_group._in_group = True - self._current_group = root_group - self._full_refresh = True + self.current_group = root_group + self.full_refresh = True return True @@ -193,38 +211,38 @@ class _DisplayCore: # pylint: disable=protected-access """Mark the display core as currently being refreshed""" - if self._refresh_in_progress: + if self.refresh_in_progress: return False - self._refresh_in_progress = True - # self._last_refresh = _Supervisor()._ticks_ms64() + self.refresh_in_progress = True + self.last_refresh = time.monotonic() * 1000 return True def finish_refresh(self) -> None: # pylint: disable=protected-access """Unmark the display core as currently being refreshed""" - if self._current_group is not None: - self._current_group._finish_refresh() + if self.current_group is not None: + self.current_group._finish_refresh() - self._full_refresh = False - self._refresh_in_progress = False - # self._last_refresh = _Supervisor()._ticks_ms64() + self.full_refresh = False + self.refresh_in_progress = False + self.last_refresh = time.monotonic() * 1000 def get_refresh_areas(self) -> list: """Get a list of areas to be refreshed""" subrectangles = [] - if self._current_group is not None: + if self.current_group is not None: # Eventually calculate dirty rectangles here - subrectangles.append(RectangleStruct(0, 0, self._width, self._height)) + subrectangles.append(RectangleStruct(0, 0, self.width, self.height)) return subrectangles def release(self) -> None: """Release the display from the current group""" # pylint: disable=protected-access - if self._current_group is not None: - self._current_group._in_group = False + if self.current_group is not None: + self.current_group._in_group = False def fill_area( self, @@ -235,22 +253,22 @@ class _DisplayCore: # pylint: disable=protected-access """Call the current group's fill area function""" - return self._current_group._fill_area(self._colorspace, area, mask, buffer) + return self.current_group._fill_area(self.colorspace, area, mask, buffer) def clip_area(self, area: Area, clipped: Area) -> bool: """Shrink the area to the region shared by the two areas""" # pylint: disable=protected-access - overlaps = self._area._compute_overlap(area, clipped) + overlaps = self.area._compute_overlap(area, clipped) if not overlaps: return False # Expand the area if we have multiple pixels per byte and we need to byte align the bounds - if self._colorspace.depth < 8: + if self.colorspace.depth < 8: pixels_per_byte = ( - 8 // self._colorspace.depth * self._colorspace.bytes_per_cell + 8 // self.colorspace.depth * self.colorspace.bytes_per_cell ) - if self._colorspace.pixels_in_byte_share_row: + if self.colorspace.pixels_in_byte_share_row: if clipped.x1 % pixels_per_byte != 0: clipped.x1 -= clipped.x1 % pixels_per_byte if clipped.x2 % pixels_per_byte != 0: @@ -290,21 +308,21 @@ class _DisplayCore: """ Gets the width of the display in pixels. """ - return self._width + return self.width def get_height(self) -> int: """ Gets the height of the display in pixels. """ - return self._height + return self.height def get_rotation(self) -> int: """ Gets the rotation of the display as an int in degrees. """ - return self._rotation + return self.rotation - def get_bus(self) -> Union[FourWire, ParallelBus, I2CDisplay]: + def get_bus(self) -> _DisplayBus: """ The bus being used by the display. [readonly] """ diff --git a/displayio/_i2cdisplay.py b/displayio/_i2cdisplay.py index 42f85ec..b5de381 100644 --- a/displayio/_i2cdisplay.py +++ b/displayio/_i2cdisplay.py @@ -53,6 +53,14 @@ class I2CDisplay: self._i2c = i2c_bus self._dev_addr = device_address + def __new__(cls, *args, **kwargs): + from . import ( # pylint: disable=import-outside-toplevel, cyclic-import + allocate_display_bus, + ) + + allocate_display_bus(cls) + return super().__new__(cls) + def _release(self): self.reset() self._i2c.deinit() diff --git a/terminalio.py b/terminalio.py index ce177e8..c346ceb 100644 --- a/terminalio.py +++ b/terminalio.py @@ -17,7 +17,6 @@ terminalio for Blinka """ -import sys # pylint: disable=unused-import import fontio __version__ = "0.0.0+auto.0" @@ -26,5 +25,7 @@ __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git" FONT = fontio.BuiltinFont() # TODO: Tap into stdout to get the REPL +# Look at how Adafruit_Python_Shell's run_command works as an option +# Additionally, adding supervisor to Blinka may be helpful to keep track of REPL output # sys.stdout = open('out.dat', 'w') # sys.stdout.close() -- 2.49.0