Core. Refresh control: replace Event instances with a Lock.

pull/55/head
Peter Hinch 2024-09-25 10:11:31 +01:00
rodzic c6cdf80800
commit 868ea26f99
3 zmienionych plików z 110 dodań i 129 usunięć

Wyświetl plik

@ -65,18 +65,13 @@ target and a C device driver (unless you can acquire a suitable binary).
# Project status
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.
April 2024: Add screen replace feature for non-tree navigation.
Sept 2023: Add "encoder only" mode suggested by @eudoxos.
April 2023: Add limited ePaper support, grid widget, calendar and epaper demos.
Now requires firmware >= V1.20.
July 2022: Add ESP32 touch pad support.
June 2022: Add [QRMap](./README.md#620-qrmap-widget) and
[BitMap](./README.md#619-bitmap-widget) widgets.
March 2022: Add [latency control](./README.md#45-class-variable) for hosts with
SPIRAM.
February 2022: Supports use with only three buttons devised by Bart Cerneels.
Simplified widget import. Existing users should replace the entire `gui` tree.
Code has been tested on ESP32, ESP32-S2, ESP32-S3, Pi Pico and Pyboard. This is
under development so check for updates.
@ -3179,41 +3174,36 @@ docs on `pushbutton.py` may be found
# 9. Realtime applications
Screen refresh is performed in a continuous loop with yields to the scheduler.
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. A means of synchronising
refresh to other tasks is provided, enabling the application to control the
screen refresh. This is done by means of two `Event` instances. The refresh
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).
```python
class Screen:
rfsh_start = Event() # Refresh pauses until set (set by default).
rfsh_done = Event() # Flag a user task that a refresh was done.
rfsh_lock = Lock() # Refresh pauses until lock is acquired
@classmethod
async def auto_refresh(cls):
cls.rfsh_start.set()
while True:
await cls.rfsh_start.wait()
ssd.show() # Synchronous (blocking) refresh.
async with cls.rfsh_lock:
ssd.show() # Refresh the physical display.
# Flag user code.
cls.rfsh_done.set()
await asyncio.sleep_ms(0) # Let user code respond to event
```
By default the `rfsh_start` event is permanently set, allowing refresh to free
run. User code can clear this event to delay refresh. The `rfsh_done` event can
signal to user code that refresh is complete. As an example of simple usage,
the following, if awaited, pauses until a refresh is complete and prevents
another from occurring.
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:
```python
async def refresh_and_stop(self):
Screen.rfsh_start.set() # Allow refresh
Screen.rfsh_done.clear() # Enable completion flag
await Screen.rfsh_done.wait() # Wait for a refresh to end
Screen.rfsh_start.clear() # Prevent another.
async with Screen.rfsh_lock:
# do something that can't be interrupted with a refresh
```
The demo `gui/demos/audio.py` provides example usage.
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.
@ -3500,41 +3490,41 @@ CPU activity remains high. The following script may be used to confirm this.
import hardware_setup # Create a display instance
from gui.core.ugui import Screen, ssd
from gui.widgets import Label, Button, CloseButton
from gui.widgets import Label, Button, CloseButton, LED
from gui.core.writer import CWriter
import gui.fonts.arial10 as arial10
from gui.core.colors import *
import asyncio
async def refresh_and_stop():
Screen.rfsh_start.set() # Allow refresh
Screen.rfsh_done.clear() # Enable completion flag
await Screen.rfsh_done.wait() # Wait for a refresh to end
Screen.rfsh_start.clear() # Prevent another.
print("Refresh stopped")
async def stop_rfsh():
await Screen.rfsh_lock.acquire()
def cby(_):
asyncio.create_task(refresh_and_stop())
asyncio.create_task(stop_rfsh())
def cbn(_):
Screen.rfsh_start.set() # Allow refresh
print("Refresh started.")
Screen.rfsh_lock.release() # Allow refresh
class BaseScreen(Screen):
def __init__(self):
super().__init__()
wri = CWriter(ssd, arial10, GREEN, BLACK)
wri = CWriter(ssd, arial10, GREEN, BLACK, verbose=False)
col = 2
row = 2
Label(wri, row, col, "Refresh test")
self.led = LED(wri, row, 80)
row = 50
Button(wri, row, col, text="Stop", callback=cby)
col += 60
Button(wri, row, col, text="Start", callback=cbn)
self.reg_task(self.flash())
CloseButton(wri) # Quit
async def flash(self): # Proof of stopped refresh
while True:
self.led.value(not self.led.value())
await asyncio.sleep_ms(300)
def test():
print("Refresh test.")

Wyświetl plik

@ -25,7 +25,7 @@ ssd = None
_vb = True
gc.collect()
__version__ = (0, 1, 8)
__version__ = (0, 1, 9)
async def _g():
@ -305,10 +305,8 @@ class Screen:
do_gc = True # Allow user to take control of GC
current_screen = None
is_shutdown = asyncio.Event()
# These events enable user code to synchronise display refresh
# to a realtime process.
rfsh_start = asyncio.Event() # Refresh pauses until set (set by default).
rfsh_done = asyncio.Event() # Flag a user task that a refresh was done.
# The lock enables user code to synchronise refresh with a realtime process.
rfsh_lock = asyncio.Lock()
BACK = 0
STACK = 1
REPLACE = 2
@ -417,24 +415,22 @@ class Screen:
@classmethod
async def auto_refresh(cls):
arfsh = hasattr(ssd, "do_refresh") # Refresh can be asynchronous.
# By default rfsh_start is permanently set. User code can clear this.
cls.rfsh_start.set()
if arfsh:
h = ssd.height
split = max(y for y in (1, 2, 3, 5, 7) if not h % y)
if split == 1:
arfsh = False
while True:
await cls.rfsh_start.wait()
Screen.show(False) # Update stale controls. No physical refresh.
# Now perform physical refresh.
if arfsh:
await ssd.do_refresh(split)
else:
ssd.show() # Synchronous (blocking) refresh.
# Flag user code.
cls.rfsh_done.set()
await asyncio.sleep_ms(0) # Let user code respond to event
# Now perform physical refresh. If there is no user locking,
# the lock will be acquired immediately
async with cls.rfsh_lock:
await asyncio.sleep_ms(0) # Allow other tasks to detect lock
if arfsh:
await ssd.do_refresh(split)
else:
ssd.show() # Synchronous (blocking) refresh.
await asyncio.sleep_ms(0) # Let user code respond to lock release
@classmethod
def back(cls):

Wyświetl plik

@ -1,7 +1,7 @@
# audio.py
# audio.py
# Released under the MIT License (MIT). See LICENSE.
# Copyright (c) 2021 Peter Hinch
# Copyright (c) 2021-2024 Peter Hinch
import hardware_setup # Create a display instance
from gui.core.ugui import Screen, ssd
@ -12,8 +12,9 @@ import pyb
# ***************
# Do allocations early
BUFSIZE = 1024*20 # 5.8ms/KiB 8KiB occasional dropouts
WAVSIZE = 1024*2
BUFSIZE = 1024 * 20 # 5.8ms/KiB 8KiB occasional dropouts
WAVSIZE = 1024 * 2
_RFSH_GATE = const(10) # While playing, reduce refresh rate
root = "/sd/music" # Location of directories containing albums
@ -29,15 +30,15 @@ wav_samples = bytearray(WAVSIZE)
# https://github.com/miketeachman/micropython-i2s-examples/blob/master/examples/wavplayer.py
# Here for simplicity we assume stereo files ripped from CD's.
config = {
'sck' : Pin('W29'),
'ws' : Pin('W16'),
'sd' : Pin('Y4'),
'mode' : I2S.TX,
'bits' : 16, # Sample size in bits/channel
'format' : I2S.STEREO,
'rate' : 44100, # Sample rate in Hz
'ibuf' : BUFSIZE, # Buffer size
}
"sck": Pin("W29"),
"ws": Pin("W16"),
"sd": Pin("Y4"),
"mode": I2S.TX,
"bits": 16, # Sample size in bits/channel
"format": I2S.STEREO,
"rate": 44100, # Sample rate in Hz
"ibuf": BUFSIZE, # Buffer size
}
audio_out = I2S(I2S_ID, **config)
@ -68,38 +69,39 @@ except OSError:
print(f"Expected {root} directory not found.")
sys.exit(1)
class SelectScreen(Screen):
songs = []
album = ""
def __init__(self, wri):
super().__init__()
Listbox(wri, 2, 2, elements = subdirs, dlines = 8, width=100, callback = self.lbcb)
Listbox(wri, 2, 2, elements=subdirs, dlines=8, width=100, callback=self.lbcb)
def lbcb(self, lb): # sort
directory = ''.join((root, '/', lb.textvalue()))
directory = "".join((root, "/", lb.textvalue()))
songs = [x[0] for x in os.ilistdir(directory) if x[1] != 0x4000]
songs.sort()
SelectScreen.songs = [''.join((directory, '/', x)) for x in songs]
SelectScreen.songs = ["".join((directory, "/", x)) for x in songs]
SelectScreen.album = lb.textvalue()
Screen.back()
class BaseScreen(Screen):
def __init__(self):
self.swriter = asyncio.StreamWriter(audio_out)
args = {
'bdcolor' : RED,
'slotcolor' : BLUE,
'legends' : ('-48dB', '-24dB', '0dB'),
'value' : 0.5,
'height' : 15,
}
"bdcolor": RED,
"slotcolor": BLUE,
"legends": ("-48dB", "-24dB", "0dB"),
"value": 0.5,
"height": 15,
}
buttons = {
'shape' : CIRCLE,
'fgcolor' : GREEN,
}
"shape": CIRCLE,
"fgcolor": GREEN,
}
super().__init__()
# Audio status
self.playing = False # Track is playing
@ -112,12 +114,12 @@ class BaseScreen(Screen):
wri = CWriter(ssd, arial10, GREEN, BLACK, False)
wri_icons = CWriter(ssd, icons, WHITE, BLACK, False)
Button(wri_icons, 2, 2, text='E', callback=self.new, args=(wri,), **buttons) # New
Button(wri_icons, row := 30, col := 2, text='D', callback=self.replay, **buttons) # Replay
Button(wri_icons, row, col := col + 25, text='F', callback=self.play_cb, **buttons) # Play
Button(wri_icons, row, col := col + 25, text='B', callback=self.pause, **buttons) # Pause
Button(wri_icons, row, col := col + 25, text='A', callback=self.stop, **buttons) # Stop
Button(wri_icons, row, col + 25, text='C', callback=self.skip, **buttons) # Skip
Button(wri_icons, 2, 2, text="E", callback=self.new, args=(wri,), **buttons) # New
Button(wri_icons, row := 30, col := 2, text="D", callback=self.replay, **buttons) # Replay
Button(wri_icons, row, col := col + 25, text="F", callback=self.play_cb, **buttons) # Play
Button(wri_icons, row, col := col + 25, text="B", callback=self.pause, **buttons) # Pause
Button(wri_icons, row, col := col + 25, text="A", callback=self.stop, **buttons) # Stop
Button(wri_icons, row, col + 25, text="C", callback=self.skip, **buttons) # Skip
row = 60
col = 2
self.lbl = Label(wri, row, col, 120)
@ -128,7 +130,6 @@ class BaseScreen(Screen):
CloseButton(wri) # Quit the application
# self.reg_task(asyncio.create_task(self.report()))
async def report(self):
while True:
gc.collect()
@ -159,19 +160,24 @@ class BaseScreen(Screen):
self.stop_play = True # Replay from start
self.paused = False
self.show_song()
#self.play_album()
# self.play_album()
def skip(self, _):
self.stop_play = True
self.paused = False
self.song_idx = min(self.song_idx + 1, len(self.songs) -1)
self.song_idx = min(self.song_idx + 1, len(self.songs) - 1)
self.show_song()
#self.play_album()
# self.play_album()
def new(self, _, wri):
self.stop_play = True
self.paused = False
Screen.change(SelectScreen, args=[wri,])
Screen.change(
SelectScreen,
args=[
wri,
],
)
def play_album(self):
if not self.playing:
@ -183,30 +189,14 @@ class BaseScreen(Screen):
if self.songs:
self.song_idx = 0 # Start on track 0
self.show_song()
#self.play_album()
# self.play_album()
def show_song(self): # 13ms
song = self.songs[self.song_idx]
ns = song.find(SelectScreen.album)
ne = song[ns:].find('/') + 1
end = song[ns + ne:].find(".wav")
self.lblsong.value(song[ns + ne: ns + ne + end])
async def refresh_and_stop(self):
Screen.rfsh_start.set() # Allow refresh
Screen.rfsh_done.clear()
await Screen.rfsh_done.wait() # Wait for a refresh to end
Screen.rfsh_start.clear() # Prevent another.
async def refresh_ctrl(self): # Enter with refresh paused
await asyncio.sleep_ms(100) # Time for initial buffer fill
try:
while True:
await self.refresh_and_stop() # Allow one screen refresh
await asyncio.sleep_ms(20) # Time for buffer top-up
finally: # Allow refresh to free-run
Screen.rfsh_start.set()
ne = song[ns:].find("/") + 1
end = song[ns + ne :].find(".wav")
self.lblsong.value(song[ns + ne : ns + ne + end])
async def album_task(self):
self.playing = True # Prevent other instances
@ -215,12 +205,7 @@ class BaseScreen(Screen):
songs = self.songs[self.song_idx :] # Start from current index
for song in songs:
self.show_song()
await self.refresh_and_stop() # Pause until refresh is stopped.
# Delay refresh to ensure buffer is filled
rc = asyncio.create_task(self.refresh_ctrl())
await asyncio.sleep_ms(0)
await self.play_song(song)
rc.cancel() # Restore normal display refresh
if self.stop_play:
break # A callback has stopped playback
self.song_idx += 1
@ -228,11 +213,11 @@ class BaseScreen(Screen):
self.song_idx = 0 # Played to completion.
self.show_song()
self.playing = False
rc.cancel() # Restore normal display refresh
# Open and play a binary wav file
async def play_song(self, song):
wav_samples_mv = memoryview(wav_samples)
lock = Screen.rfsh_lock
size = len(wav_samples)
if not self.paused:
# advance to first byte of Data section in WAV file. This is not
@ -241,20 +226,30 @@ class BaseScreen(Screen):
swriter = self.swriter
with open(song, "rb") as wav:
_ = wav.seek(self.offset)
while (num_read := wav.readinto(wav_samples_mv)) and not self.stop_play:
I2S.shift(buf=wav_samples_mv[:num_read], bits=16, shift=self.volume)
# HACK awaiting https://github.com/micropython/micropython/pull/7868
swriter.out_buf = wav_samples_mv[:num_read]
await swriter.drain()
# wav_samples is now empty
self.offset += size
while not self.stop_play:
async with lock:
n = 0
while n < _RFSH_GATE:
if not (num_read := wav.readinto(wav_samples_mv)): # Song end
self.stop_play = True
break
I2S.shift(buf=wav_samples_mv[:num_read], bits=16, shift=self.volume)
# HACK awaiting https://github.com/micropython/micropython/pull/7868
swriter.out_buf = wav_samples_mv[:num_read]
await swriter.drain()
# wav_samples is now empty. Save offset in case we pause play.
self.offset += size
n += 1
await asyncio.sleep_ms(0) # Allow refresh to grab lock
def test():
print('Audio demo.')
print("Audio demo.")
try:
Screen.change(BaseScreen) # A class is passed here, not an instance.
finally:
audio_out.deinit()
print("========== CLOSE AUDIO ==========")
test()