Skip to content

Commit

Permalink
Enhanced refresh lock functionality.
Browse files Browse the repository at this point in the history
  • Loading branch information
peterhinch committed Oct 1, 2024
1 parent 868ea26 commit 67e1e8e
Show file tree
Hide file tree
Showing 10 changed files with 350 additions and 112 deletions.
91 changes: 68 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
This is a lightweight, portable, MicroPython GUI library for displays having
drivers subclassed from `framebuf`. Written in Python it runs under a standard
MicroPython firmware build. Options for data input comprise:
* Two pushbuttons: limited capabilities with some widgets unusable for input.
* Three pushbuttons with full capability.
* Five pushbuttons: full capability, less "modal" interface.
* Two pushbuttons: restricted capabilities with some widgets unusable for input.
* All the following options offer full capability:
* Three pushbuttons.
* Five pushbuttons: extra buttons provide a less "modal" interface.
* A switch-based navigation joystick: another way to implement five buttons.
* Via two pushbuttons and a rotary encoder such as
* Two pushbuttons and a rotary encoder such as
[this one](https://www.adafruit.com/product/377). An intuitive interface.
* A rotary encoder with built-in push switch only.
* On ESP32 physical buttons may be replaced with touchpads.

It is larger and more complex than `nano-gui` owing to the support for input.
Expand Down Expand Up @@ -65,6 +67,7 @@ target and a C device driver (unless you can acquire a suitable binary).

# Project status

Oct 2024: Oct 2024: Refresh locking can now be handled by device driver.
Sept 2024: Refresh control is now via a `Lock`. See [Realtime applications](./README.md#9-realtime-applications).
This is a breaking change for applications which use refresh control.
Sept 2024: Dropdown and Listbox widgets support dynamically variable lists of elements.
Expand Down Expand Up @@ -686,6 +689,8 @@ Some of these require larger screens. Required sizes are specified as
* `listbox_var.py` Listbox with dynamically variable elements.
* `dropdown_var.py` Dropdown with dynamically variable elements.
* `dropdown_var_tuple.py ` Dropdown with dynamically variable tuple elements.
* `refresh_lock.py` Specialised demo of an application which controls refresh
behaviour. See [Realtime applications](./README.md#8-realtime-applications).

###### [Contents](./README.md#0-contents)

Expand Down Expand Up @@ -3174,36 +3179,76 @@ docs on `pushbutton.py` may be found

# 9. Realtime applications

Screen refresh is performed in a continuous loop which yields to the scheduler.
In normal applications this works well, however a significant proportion of
processor time is spent performing a blocking refresh. The `asyncio` scheduler
allocates run time to tasks in round-robin fashion. This means that another task
will normally be scheduled once per screen refresh. This can limit data
throughput. To enable applications to handle this, a means of synchronising
refresh to other tasks is provided. This is via a `Lock` instance. The refresh
task operates as below (code simplified to illustrate this mechanism).
These notes assume an application based on `asyncio` that needs to handle events
occurring in real time. There are two ways in which the GUI might affect real
time performance:
* By imposing latency on the scheduling of tasks.
* By making demands on processing power such that a critical task is starved of
execution.

The GUI uses `asyncio` internally and runs a number of tasks. Most of these are
simple and undemanding, the one exception being refresh. This has to copy the
contents of the frame buffer to the hardware, and runs continuously. The way
this works depends on the display type. On small displays with relatively few
pixels it is a blocking, synchronous method. On bigger screens such a method
would block for many tens of ms which would affect latency which would affect
the responsiveness of the user interface. The drivers for such screens have an
asynchronous `do_refresh` method: this divides the refresh into a small number
of segments, each of which blocks for a short period, preserving responsiveness.

In the great majority of applications this works well. For demanding cases a
user-accessible `Lock` is provided to enable refresh to be paused. This is
`Screen.rfsh_lock`. Further, the behaviour of this `Lock` can be modified. By
default the refresh task will hold the `Lock` for the entire duration of a
refresh. Alternatively the `Lock` can be held for the duration of the update of
one segment. In testing on a Pico with ILI9341 the `Lock` duration was reduced
from 95ms to 11.3ms. If an application has a task which needs to be scheduled at
a high rate, this corresponds to an increase from 10Hz to 88Hz.

The mechanism for controlling lock behaviour is a method of the `ssd` instance:
* `short_lock(v=None)` If `True` is passed, the `Lock` will be held briefly,
`False` will cause it to be held for the entire refresh, `None` makes no change.
The method returns the current state. Note that only the larger display drivers
support this method.

The following (pseudocode, simplified) illustrates this mechanism:
```python
class Screen:
rfsh_lock = Lock() # Refresh pauses until lock is acquired

@classmethod
async def auto_refresh(cls):
while True:
async with cls.rfsh_lock:
ssd.show() # Refresh the physical display.
# Flag user code.
await asyncio.sleep_ms(0) # Let user code respond to event
```
User code can wait on the lock and, once acquired, perform an operation which
cannot be interrupted by a refresh. This is normally done as follows:
if display_supports_segmented_refresh and short_lock_is_enabled:
# At intervals yield and release the lock
await ssd.do_refresh(split, cls.rfsh_lock)
else: # Lock for the entire refresh
await asyncio.sleep_ms(0) # Let user code respond to event
async with cls.rfsh_lock:
if display_supports_segmented_refresh:
# Yield at intervals (retaining lock)
await ssd.do_refresh(split) # Segmented refresh
else:
ssd.show() # Blocking synchronous refresh on small screen.
```
User code can wait on the lock and, once acquired, run asynchronous code which
cannot be interrupted by a refresh. This is normally done with an asynchronous
context manager:
```python
async with Screen.rfsh_lock:
# do something that can't be interrupted with a refresh
```
The demo `gui/demos/audio.py` provides an example, where the `play_song` task
gives priority to maintaining the audio buffer. It does this by holding the lock
for several iterations of buffer filling before releasing the lock to allow a
single refresh.
The demo `refresh_lock.py` illustrates this mechanism, allowing refresh to be
started and stopped. The demo also allows the `short_lock` method to be tested,
with a display of the scheduling rate of a minimal locked task. In a practical
application this rate is dependant on various factors. A number of debugging
aids exist to assist in measuring and optimising this. See
[this doc](https://github.com/peterhinch/micropython-async/blob/master/v3/README.md).

The demo `gui/demos/audio.py`
provides an example, where the `play_song` task gives priority to maintaining
the audio buffer. It does this by holding the lock for several iterations of
buffer filling before releasing the lock to allow a single refresh.

See [Appendix 4 GUI Design notes](./README.md#appendix-4-gui-design-notes) for
the reason for continuous refresh.
Expand Down
29 changes: 20 additions & 9 deletions drivers/gc9a01/gc9a01.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def __init__(
self._cs = cs
self._dc = dc
self._rst = rst
self.lock_mode = False # If set, user lock is passed to .do_refresh
self.height = height # Logical dimensions for GUIs
self.width = width
self._spi_init = init_spi
Expand Down Expand Up @@ -202,7 +203,16 @@ def show(self): # Physical display is in portrait mode
self._spi.write(lb)
self._cs(1)

async def do_refresh(self, split=4):
def short_lock(self, v=None):
if v is not None:
self.lock_mode = v # If set, user lock is passed to .do_refresh
return self.lock_mode

# nanogui apps typically call with no args. ugui and tgui pass split and
# may pass a Lock depending on lock_mode
async def do_refresh(self, split=4, elock=None):
if elock is None:
elock = asyncio.Lock()
async with self._lock:
lines, mod = divmod(self.height, split) # Lines per segment
if mod:
Expand All @@ -216,12 +226,13 @@ async def do_refresh(self, split=4):
cm = self._gscale # color False, greyscale True
line = 0
for _ in range(split): # For each segment
if self._spi_init: # A callback was passed
self._spi_init(self._spi) # Bus may be shared
self._cs(0)
for start in range(wd * line, wd * (line + lines), wd): # For each line
_lcopy(lb, buf[start:], clut, wd, cm) # Copy and map colors
self._spi.write(lb)
line += lines
self._cs(1) # Allow other tasks to use bus
async with elock:
if self._spi_init: # A callback was passed
self._spi_init(self._spi) # Bus may be shared
self._cs(0)
for start in range(wd * line, wd * (line + lines), wd): # For each line
_lcopy(lb, buf[start:], clut, wd, cm) # Copy and map colors
self._spi.write(lb)
line += lines
self._cs(1) # Allow other tasks to use bus
await asyncio.sleep_ms(0)
29 changes: 20 additions & 9 deletions drivers/gc9a01/gc9a01_8_bit.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def __init__(
self._cs = cs
self._dc = dc
self._rst = rst
self.lock_mode = False # If set, user lock is passed to .do_refresh
self.height = height # Logical dimensions for GUIs
self.width = width
self._spi_init = init_spi
Expand Down Expand Up @@ -182,7 +183,16 @@ def show(self): # Physical display is in portrait mode
self._spi.write(lb)
self._cs(1)

async def do_refresh(self, split=4):
def short_lock(self, v=None):
if v is not None:
self.lock_mode = v # If set, user lock is passed to .do_refresh
return self.lock_mode

# nanogui apps typically call with no args. ugui and tgui pass split and
# may pass a Lock depending on lock_mode
async def do_refresh(self, split=4, elock=None):
if elock is None:
elock = asyncio.Lock()
async with self._lock:
lines, mod = divmod(self.height, split) # Lines per segment
if mod:
Expand All @@ -194,12 +204,13 @@ async def do_refresh(self, split=4):
wd = self.width
line = 0
for _ in range(split): # For each segment
if self._spi_init: # A callback was passed
self._spi_init(self._spi) # Bus may be shared
self._cs(0)
for start in range(wd * line, wd * (line + lines), wd): # For each line
_lcopy(lb, buf[start:], wd) # Copy and map colors
self._spi.write(lb)
line += lines
self._cs(1) # Allow other tasks to use bus
async with elock:
if self._spi_init: # A callback was passed
self._spi_init(self._spi) # Bus may be shared
self._cs(0)
for start in range(wd * line, wd * (line + lines), wd): # For each line
_lcopy(lb, buf[start:], wd) # Copy and map colors
self._spi.write(lb)
line += lines
self._cs(1) # Allow other tasks to use bus
await asyncio.sleep_ms(0)
29 changes: 20 additions & 9 deletions drivers/ili93xx/ili9341.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def __init__(self, spi, cs, dc, rst, height=240, width=320, usd=False, init_spi=
self._cs = cs
self._dc = dc
self._rst = rst
self.lock_mode = False # If set, user lock is passed to .do_refresh
self.height = height
self.width = width
self._spi_init = init_spi
Expand Down Expand Up @@ -156,7 +157,16 @@ def show(self):
self._spi.write(lb)
self._cs(1)

async def do_refresh(self, split=4):
def short_lock(self, v=None):
if v is not None:
self.lock_mode = v # If set, user lock is passed to .do_refresh
return self.lock_mode

# nanogui apps typically call with no args. ugui and tgui pass split and
# may pass a Lock depending on lock_mode
async def do_refresh(self, split=4, elock=None):
if elock is None:
elock = asyncio.Lock()
async with self._lock:
lines, mod = divmod(self.height, split) # Lines per segment
if mod:
Expand All @@ -174,12 +184,13 @@ async def do_refresh(self, split=4):
self._dc(1)
line = 0
for _ in range(split): # For each segment
if self._spi_init: # A callback was passed
self._spi_init(self._spi) # Bus may be shared
self._cs(0)
for start in range(wd * line, wd * (line + lines), wd): # For each line
_lcopy(lb, buf[start:], clut, wd, cm) # Copy and map colors
self._spi.write(lb)
line += lines
self._cs(1) # Allow other tasks to use bus
async with elock:
if self._spi_init: # A callback was passed
self._spi_init(self._spi) # Bus may be shared
self._cs(0)
for start in range(wd * line, wd * (line + lines), wd): # For each line
_lcopy(lb, buf[start:], clut, wd, cm) # Copy and map colors
self._spi.write(lb)
line += lines
self._cs(1) # Allow other tasks to use bus
await asyncio.sleep_ms(0)
29 changes: 20 additions & 9 deletions drivers/ili93xx/ili9341_8bit.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def __init__(self, spi, cs, dc, rst, height=240, width=320, usd=False, init_spi=
self._cs = cs
self._dc = dc
self._rst = rst
self.lock_mode = False # If set, user lock is passed to .do_refresh
self.height = height
self.width = width
self._spi_init = init_spi
Expand Down Expand Up @@ -134,7 +135,16 @@ def show(self):
self._spi.write(lb)
self._cs(1)

async def do_refresh(self, split=4):
def short_lock(self, v=None):
if v is not None:
self.lock_mode = v # If set, user lock is passed to .do_refresh
return self.lock_mode

# nanogui apps typically call with no args. ugui and tgui pass split and
# may pass a Lock depending on lock_mode
async def do_refresh(self, split=4, elock=None):
if elock is None:
elock = asyncio.Lock()
async with self._lock:
lines, mod = divmod(self.height, split) # Lines per segment
if mod:
Expand All @@ -150,12 +160,13 @@ async def do_refresh(self, split=4):
self._dc(1)
line = 0
for _ in range(split): # For each segment
if self._spi_init: # A callback was passed
self._spi_init(self._spi) # Bus may be shared
self._cs(0)
for start in range(wd * line, wd * (line + lines), wd): # For each line
_lcopy(lb, buf[start:], wd) # Copy and map colors
self._spi.write(lb)
line += lines
self._cs(1) # Allow other tasks to use bus
async with elock:
if self._spi_init: # A callback was passed
self._spi_init(self._spi) # Bus may be shared
self._cs(0)
for start in range(wd * line, wd * (line + lines), wd): # For each line
_lcopy(lb, buf[start:], wd) # Copy and map colors
self._spi.write(lb)
line += lines
self._cs(1) # Allow other tasks to use bus
await asyncio.sleep_ms(0)
48 changes: 30 additions & 18 deletions drivers/ili94xx/ili9486.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def __init__(
self._cs = cs
self._dc = dc
self._rst = rst
self.lock_mode = False # If set, user lock is passed to .do_refresh
self.height = height # Logical dimensions for GUIs
self.width = width
self._long = max(height, width) # Physical dimensions of screen and aspect ratio
Expand Down Expand Up @@ -180,7 +181,16 @@ def show(self): # Physical display is in portrait mode
self._spi.write(lb)
self._cs(1)

async def do_refresh(self, split=4):
def short_lock(self, v=None):
if v is not None:
self.lock_mode = v # If set, user lock is passed to .do_refresh
return self.lock_mode

# nanogui apps typically call with no args. ugui and tgui pass split and
# may pass a Lock depending on lock_mode
async def do_refresh(self, split=4, elock=None):
if elock is None:
elock = asyncio.Lock()
async with self._lock:
lines, mod = divmod(self._long, split) # Lines per segment
if mod:
Expand All @@ -195,27 +205,29 @@ async def do_refresh(self, split=4):
wd = self.width // 2
line = 0
for _ in range(split): # For each segment
if self._spi_init: # A callback was passed
self._spi_init(self._spi) # Bus may be shared
self._cs(0)
for start in range(wd * line, wd * (line + lines), wd): # For each line
_lcopy(lb, buf[start:], clut, wd, cm) # Copy and map colors
self._spi.write(lb)
line += lines
self._cs(1) # Allow other tasks to use bus
async with elock:
if self._spi_init: # A callback was passed
self._spi_init(self._spi) # Bus may be shared
self._cs(0)
for start in range(wd * line, wd * (line + lines), wd): # For each line
_lcopy(lb, buf[start:], clut, wd, cm) # Copy and map colors
self._spi.write(lb)
line += lines
self._cs(1) # Allow other tasks to use bus
await asyncio.sleep_ms(0)
else: # Landscape: write sets of cols. lines is no. of cols per segment.
cargs = (self.height << 9) + (self.width << 18) # Viper 4-arg limit
sc = self.width - 1 # Start and end columns
ec = sc - lines # End column
for _ in range(split): # For each segment
if self._spi_init: # A callback was passed
self._spi_init(self._spi) # Bus may be shared
self._cs(0)
for col in range(sc, ec, -1): # For each column of landscape display
_lscopy(lb, buf, clut, col + cargs, cm) # Copy and map colors
self._spi.write(lb)
sc -= lines
ec -= lines
self._cs(1) # Allow other tasks to use bus
async with elock:
if self._spi_init: # A callback was passed
self._spi_init(self._spi) # Bus may be shared
self._cs(0)
for col in range(sc, ec, -1): # For each column of landscape display
_lscopy(lb, buf, clut, col + cargs, cm) # Copy and map colors
self._spi.write(lb)
sc -= lines
ec -= lines
self._cs(1) # Allow other tasks to use bus
await asyncio.sleep_ms(0)
Loading

0 comments on commit 67e1e8e

Please sign in to comment.