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 # 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. Sept 2024: Dropdown and Listbox widgets support dynamically variable lists of elements.
April 2024: Add screen replace feature for non-tree navigation. April 2024: Add screen replace feature for non-tree navigation.
Sept 2023: Add "encoder only" mode suggested by @eudoxos. Sept 2023: Add "encoder only" mode suggested by @eudoxos.
April 2023: Add limited ePaper support, grid widget, calendar and epaper demos. April 2023: Add limited ePaper support, grid widget, calendar and epaper demos.
Now requires firmware >= V1.20. 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 Code has been tested on ESP32, ESP32-S2, ESP32-S3, Pi Pico and Pyboard. This is
under development so check for updates. under development so check for updates.
@ -3179,41 +3174,36 @@ docs on `pushbutton.py` may be found
# 9. Realtime applications # 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 In normal applications this works well, however a significant proportion of
processor time is spent performing a blocking refresh. A means of synchronising processor time is spent performing a blocking refresh. The `asyncio` scheduler
refresh to other tasks is provided, enabling the application to control the allocates run time to tasks in round-robin fashion. This means that another task
screen refresh. This is done by means of two `Event` instances. The refresh 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). task operates as below (code simplified to illustrate this mechanism).
```python ```python
class Screen: class Screen:
rfsh_start = Event() # Refresh pauses until set (set by default). rfsh_lock = Lock() # Refresh pauses until lock is acquired
rfsh_done = Event() # Flag a user task that a refresh was done.
@classmethod @classmethod
async def auto_refresh(cls): async def auto_refresh(cls):
cls.rfsh_start.set()
while True: while True:
await cls.rfsh_start.wait() async with cls.rfsh_lock:
ssd.show() # Synchronous (blocking) refresh. ssd.show() # Refresh the physical display.
# Flag user code. # Flag user code.
cls.rfsh_done.set()
await asyncio.sleep_ms(0) # Let user code respond to event await asyncio.sleep_ms(0) # Let user code respond to event
``` ```
By default the `rfsh_start` event is permanently set, allowing refresh to free User code can wait on the lock and, once acquired, perform an operation which
run. User code can clear this event to delay refresh. The `rfsh_done` event can cannot be interrupted by a refresh. This is normally done as follows:
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.
```python ```python
async def refresh_and_stop(self): async with Screen.rfsh_lock:
Screen.rfsh_start.set() # Allow refresh # do something that can't be interrupted with a 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.
``` ```
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 See [Appendix 4 GUI Design notes](./README.md#appendix-4-gui-design-notes) for
the reason for continuous refresh. 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 import hardware_setup # Create a display instance
from gui.core.ugui import Screen, ssd 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 from gui.core.writer import CWriter
import gui.fonts.arial10 as arial10 import gui.fonts.arial10 as arial10
from gui.core.colors import * from gui.core.colors import *
import asyncio import asyncio
async def refresh_and_stop(): async def stop_rfsh():
Screen.rfsh_start.set() # Allow refresh await Screen.rfsh_lock.acquire()
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")
def cby(_): def cby(_):
asyncio.create_task(refresh_and_stop()) asyncio.create_task(stop_rfsh())
def cbn(_): def cbn(_):
Screen.rfsh_start.set() # Allow refresh Screen.rfsh_lock.release() # Allow refresh
print("Refresh started.")
class BaseScreen(Screen): class BaseScreen(Screen):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
wri = CWriter(ssd, arial10, GREEN, BLACK) wri = CWriter(ssd, arial10, GREEN, BLACK, verbose=False)
col = 2 col = 2
row = 2 row = 2
Label(wri, row, col, "Refresh test") Label(wri, row, col, "Refresh test")
self.led = LED(wri, row, 80)
row = 50 row = 50
Button(wri, row, col, text="Stop", callback=cby) Button(wri, row, col, text="Stop", callback=cby)
col += 60 col += 60
Button(wri, row, col, text="Start", callback=cbn) Button(wri, row, col, text="Start", callback=cbn)
self.reg_task(self.flash())
CloseButton(wri) # Quit 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(): def test():
print("Refresh test.") print("Refresh test.")

Wyświetl plik

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

Wyświetl plik

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