diff --git a/README.md b/README.md index dda0907..840a87c 100644 --- a/README.md +++ b/README.md @@ -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.") diff --git a/gui/core/ugui.py b/gui/core/ugui.py index 556aa62..94a2074 100644 --- a/gui/core/ugui.py +++ b/gui/core/ugui.py @@ -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): diff --git a/gui/demos/audio.py b/gui/demos/audio.py index 9441abf..6b58064 100644 --- a/gui/demos/audio.py +++ b/gui/demos/audio.py @@ -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()