From 3b6d265810f37247dd8da13f710c425c05b87203 Mon Sep 17 00:00:00 2001 From: Peter Hinch Date: Fri, 19 Jul 2024 14:01:08 +0100 Subject: [PATCH] Fix exit behaviour. Add driver pico_epaper_42_v2.py. --- README.md | 7 +- drivers/epaper/pico_epaper_42_v2.py | 354 ++++++++++++++++++++++++++++ gui/core/ugui.py | 10 +- gui/demos/epaper.py | 6 +- gui/widgets/buttons.py | 87 ++++--- 5 files changed, 431 insertions(+), 33 deletions(-) create mode 100644 drivers/epaper/pico_epaper_42_v2.py diff --git a/README.md b/README.md index 7b602a5..39879e7 100644 --- a/README.md +++ b/README.md @@ -1471,6 +1471,9 @@ comprise the currently visible button followed by its arguments. Constructor argument: * `callback=dolittle` The callback function. Default does nothing. + * `new_cb=False` When a button is pressed, determines whether the callback run + is that of the button visible when pressed, or that which becomes visible after + the press. Methods: * `add_button` Adds a button to the `ButtonList`. Arguments: as per the @@ -1491,7 +1494,9 @@ Counter intuitively, running the callback of the previous button is normal behaviour. Consider a `ButtonList` consisting of ON and OFF buttons. If ON is visible this implies that the machine under control is off. Pressing `select` causes the ON callback to run, starting the machine. The new button displayed -now reads OFF. +now reads OFF. There are situations in which the opposite behaviour is required +such as when choosing an option from a list: in this case the callback from the +newly visible button might be expected to run. Typical usage is as follows: ```python diff --git a/drivers/epaper/pico_epaper_42_v2.py b/drivers/epaper/pico_epaper_42_v2.py new file mode 100644 index 0000000..70d32e5 --- /dev/null +++ b/drivers/epaper/pico_epaper_42_v2.py @@ -0,0 +1,354 @@ +# pico_epaper_42_v2.py + +# Materials used for discovery can be found here +# https://www.waveshare.com/wiki/4.2inch_e-Paper_Module_Manual#Introduction +# Note, at the time of writing this, none of the source materials have working +# code that works with partial refresh, as the C code has a bug and all the other +# materials use that reference material as the source of truth. +# ***************************************************************************** +# * | File : pico_epaper_42_v2.py +# * | Author : michael surdouski +# * | Function : Electronic paper driver +# *---------------- +# * | This version: rev2.2 +# * | Date : 2024-05-22 +# ----------------------------------------------------------------------------- +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documnetation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS OR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# Waveshare URLs +# Main page: https://www.waveshare.com/pico-epaper-4.2.htm +# Wiki: https://www.waveshare.com/wiki/Pico-ePaper-4.2 +# Code: https://github.com/waveshareteam/Pico_ePaper_Code/blob/main/python/Pico-ePaper-4.2_V2.py + +from machine import Pin, SPI +import framebuf +import time +import asyncio +from drivers.boolpalette import BoolPalette + + +def asyncio_running(): + try: + _ = asyncio.current_task() + except: + return False + return True + + +# Display resolution +_EPD_WIDTH = const(400) +_BWIDTH = _EPD_WIDTH // 8 +_EPD_HEIGHT = const(300) + +_RST_PIN = 12 +# changed default to 7, as this can be confusing on pico -- pin 8 for SPI1 is the Rx, which overrides DC pin if miso is set to none +_DC_PIN = 7 +_CS_PIN = 9 +_BUSY_PIN = 13 + + +# Invert: EPD is black on white +# 337/141 us for 2000 bytes (125/250MHz) +@micropython.viper +def _linv(dest: ptr32, source: ptr32, length: int): + n: int = length - 1 + z: uint32 = int(0xFFFFFFFF) + while n >= 0: + dest[n] = source[n] ^ z + n -= 1 + + +class EPD(framebuf.FrameBuffer): + # A monochrome approach should be used for coding this. The rgb method ensures + # nothing breaks if users specify colors. + @staticmethod + def rgb(r, g, b): + return int((r > 127) or (g > 127) or (b > 127)) + + def __init__(self, spi=None, cs=None, dc=None, rst=None, busy=None): + self._rst = Pin(_RST_PIN, Pin.OUT) if rst is None else rst + self._busy_pin = Pin(_BUSY_PIN, Pin.IN, Pin.PULL_UP) if busy is None else busy + self._cs = Pin(_CS_PIN, Pin.OUT) if cs is None else cs + self._dc = Pin(_DC_PIN, Pin.OUT) if dc is None else dc + self._spi = SPI(1, sck=Pin(10), mosi=Pin(11), miso=Pin(28)) if spi is None else spi + self._spi.init(baudrate=4_000_000) + # Busy flag: set immediately on .show(). Cleared when busy pin is logically false. + self._busy = False + # Async API + self.updated = asyncio.Event() + self.complete = asyncio.Event() + self.maxblock = 25 + # partial refresh + self._partial = False + + # Public bound variables required by nanogui. + # Dimensions in pixels as seen by nanogui + self.width = _EPD_WIDTH + self.height = _EPD_HEIGHT + # Other public bound variable. + # Special mode enables demos written for generic displays to run. + self.demo_mode = False + self.blank_on_exit = True + + self._buf = bytearray(_EPD_HEIGHT * _BWIDTH) + self._mvb = memoryview(self._buf) + self._ibuf = bytearray(1000) # Buffer for inverted pixels + mode = framebuf.MONO_HLSB + self.palette = BoolPalette(mode) # Enable CWriter. + super().__init__(self._buf, _EPD_WIDTH, _EPD_HEIGHT, mode) + self.init() + time.sleep_ms(500) + + # Hardware reset + def reset(self): + for v in (1, 0, 1): + self._rst(v) + time.sleep_ms(20) + + def _command(self, command, data=None): + self._dc(0) + self._cs(0) + self._spi.write(command) + self._cs(1) + if data is not None: + self._data(data) + + def _data(self, data): + self._dc(1) + self._cs(0) + self._spi.write(data) + self._cs(1) + + def _display_on(self): + if self._partial: + self._command(b"\x22") + self._data(b"\xFF") + self._command(b"\x20") + else: + self._command(b"\x22") + self._data(b"\xF7") + self._command(b"\x20") + + # Called by constructor. Application use is deprecated. + def init(self): + self.reset() # hardware reset + + self._command(b"\x12") # software reset + self.wait_until_ready() + + self.set_full() + self._display_on() + + # Common API + def set_full(self): + self._partial = False + + self._command(b"\x21") # Display update control + self._data(b"\x40") + self._data(b"\x00") + + self._command(b"\x3C") # BorderWaveform + self._data(b"\x05") + + self._command(b"\x11") # data entry mode + self._data(b"\x03") # X-mode + + self._set_window() + self._set_cursor() + + self.wait_until_ready() + + def set_partial(self): + self._partial = True + + self._command(b"\x21") # Display update control + self._data(b"\x00") + self._data(b"\x00") + + self._command(b"\x3C") # BorderWaveform + self._data(b"\x80") + + self._command(b"\x11") # data entry mode + self._data(b"\x03") # X-mode + + self._set_window() + self._set_cursor() + + self.wait_until_ready() + + @micropython.native + def _bsend(self, start, nbytes): # Invert b<->w, buffer and send nbytes source bytes + buf = self._ibuf # Invert and buffer is done 32 bits at a time, hence >> 2 + _linv(buf, self._mvb[start:], nbytes >> 2) + self._dc(1) + self._cs(0) + self._spi.write(buf) + self._cs(1) + + # Send the frame buffer. If running asyncio, return whenever MAXBLOCK ms elapses + # so that caller can yield to the scheduler. + # Returns no. of bytes outstanding. + def _send_bytes(self): + fbidx = 0 # Index into framebuf + nbytes = len(self._ibuf) # Bytes to send + nleft = len(self._buf) # Size of framebuf + asyn = asyncio_running() + + def inner(): + nonlocal fbidx + nonlocal nbytes + nonlocal nleft + ts = time.ticks_ms() + while nleft > 0: + self._bsend(fbidx, nbytes) # Invert, buffer and send nbytes + fbidx += nbytes # Adjust for bytes already sent + nleft -= nbytes + nbytes = min(nbytes, nleft) + if asyn and time.ticks_diff(time.ticks_ms(), ts) > self.maxblock: + return nbytes # Probably not all done; quit. Caller yields, calls again + return 0 # All done + + return inner + + # micro-gui API; asyncio is running. + async def do_refresh(self, split): # split = 5 + assert not self._busy, "Refresh while busy" + if self._partial: + await self._as_show_partial() + else: + await self._as_show_full() + + def shutdown(self, clear=False): + time.sleep(1) # Empirically necessary (ugh) + self.fill(0) + self.set_full() + if clear or self.blank_on_exit: + self.show() + self.wait_until_ready() + self.sleep() + + # nanogui API + def show(self): + if self._busy: + raise RuntimeError("Cannot refresh: display is busy.") + if self._partial: + self._show_partial() + else: + self._show_full() + if not self.demo_mode: + # Immediate return to avoid blocking the whole application. + # User should wait for ready before calling refresh() + return + self.wait_until_ready() + time.sleep_ms(2000) # Demo mode: give time for user to see result + + def _show_full(self): + self._busy = True # Immediate busy flag. Pin goes low much later. + if asyncio_running(): + self.updated.clear() + self.complete.clear() + asyncio.create_task(self._as_show_full()) + return + + # asyncio is not running, hence sb() will not time out. + self._command(b"\x24") + sb = self._send_bytes() # Instantiate closure + sb() # Run to completion + self._command(b"\x26") + sb = self._send_bytes() # Create new instance + sb() + self._busy = False + self._display_on() + + async def _as_show_full(self): + self._command(b"\x24") + sb = self._send_bytes() # Instantiate closure + while sb(): + await asyncio.sleep_ms(0) # Timed out. Yield and continue. + + self._command(b"\x26") + sb = self._send_bytes() # New closure instance + while sb(): + await asyncio.sleep_ms(0) + + self.updated.set() + self._display_on() + while self._busy_pin(): + await asyncio.sleep_ms(0) + self._busy = False + self.complete.set() + + def _show_partial(self): + self._busy = True + if asyncio_running(): + self.updated.clear() + self.complete.clear() + asyncio.create_task(self._as_show_partial()) + return + + self._command(b"\x24") + sb = self._send_bytes() # Instantiate closure + sb() + self._busy = False + self._display_on() + + async def _as_show_partial(self): + self._command(b"\x24") + sb = self._send_bytes() # Instantiate closure + while sb(): + await asyncio.sleep_ms(0) + + self.updated.set() + self._display_on() + while self._busy_pin(): + await asyncio.sleep_ms(0) + self._busy = False + self.complete.set() + + # nanogui API + def wait_until_ready(self): + while not self.ready(): + time.sleep_ms(100) + + def ready(self): + return not (self._busy or self._busy_pin()) # 1 == busy + + def sleep(self): + self._command(b"\x10") # deep sleep + self._data(b"\x01") + + # window and cursor always the same for 4.2" + def _set_window(self): + self._command(b"\x44") + self._data(b"\x00") + self._data(b"\x31") + + self._command(b"\x45") + self._data(b"\x00") + self._data(b"\x00") + self._data(b"\x2B") + self._data(b"\x01") + + def _set_cursor(self): + self._command(b"\x4E") + self._data(b"\x00") + + self._command(b"\x4F") + self._data(b"\x00") + self._data(b"\x00") diff --git a/gui/core/ugui.py b/gui/core/ugui.py index 63957a3..556aa62 100644 --- a/gui/core/ugui.py +++ b/gui/core/ugui.py @@ -389,6 +389,13 @@ class Screen: ins_new.after_open() # Optional subclass method if ins_old is None: # Initialising loop.run_until_complete(cls.monitor()) # Starts and ends uasyncio + # asyncio is no longer running + if hasattr(ssd, "shutdown"): + ssd.shutdown() # An EPD with a special shutdown method. + else: + ssd.fill(0) + ssd.show() + cls.current_screen = None # Ensure another demo can run # Don't do asyncio.new_event_loop() as it prevents re-running # the same app. @@ -403,9 +410,6 @@ class Screen: # Screen instance will be discarded: no need to worry about .tasks entry[0].cancel() await asyncio.sleep_ms(0) # Allow task cancellation to occur. - display.clr_scr() - ssd.show() - cls.current_screen = None # Ensure another demo can run # If the display driver has an async refresh method, determine the split # value which must be a factor of the height. In the unlikely event of diff --git a/gui/demos/epaper.py b/gui/demos/epaper.py index d038214..6121021 100644 --- a/gui/demos/epaper.py +++ b/gui/demos/epaper.py @@ -3,7 +3,7 @@ # Use with setup_examples/pico_epaper_42_pico.py # Released under the MIT License (MIT). See LICENSE. -# Copyright (c) 2023 Peter Hinch +# Copyright (c) 2023-2024 Peter Hinch # Initialise hardware and framebuf before importing modules. # Create SSD instance. Must be done first because of RAM use. @@ -16,6 +16,8 @@ import gui.fonts.freesans20 as large from gui.core.colors import * +# Option to leave image in place on exit: +# ssd.blank_on_exit = False # Widgets from gui.widgets import ( Label, @@ -47,10 +49,12 @@ async def full_refresh(): await Screen.rfsh_done.wait() # Wait for a single full refresh to end ssd.set_partial() + async def set_partial(): # Ensure 1st refresh is a full refresh await Screen.rfsh_done.wait() # Wait for first refresh to end ssd.set_partial() + class FooScreen(Screen): def __init__(self): buttons = [] diff --git a/gui/widgets/buttons.py b/gui/widgets/buttons.py index 5055262..5c318be 100644 --- a/gui/widgets/buttons.py +++ b/gui/widgets/buttons.py @@ -8,14 +8,30 @@ from gui.core.ugui import Screen, Widget, display from gui.primitives.delay_ms import Delay_ms from gui.core.colors import * -dolittle = lambda *_ : None +dolittle = lambda *_: None class Button(Widget): lit_time = 1000 - def __init__(self, writer, row, col, *, shape=RECTANGLE, height=20, width=50, - fgcolor=None, bgcolor=None, bdcolor=False, textcolor=None, litcolor=None, text='', - callback=dolittle, args=[]): + + def __init__( + self, + writer, + row, + col, + *, + shape=RECTANGLE, + height=20, + width=50, + fgcolor=None, + bgcolor=None, + bdcolor=False, + textcolor=None, + litcolor=None, + text="", + callback=dolittle, + args=[] + ): sl = writer.stringlen(text) if shape == CIRCLE: # Only height need be specified width = max(sl, height) @@ -40,7 +56,7 @@ class Button(Widget): y = self.row w = self.width h = self.height - if not self.visible: # erase the button + if not self.visible: # erase the button display.usegrey(False) display.fill_rect(x, y, w, h, BGCOLOR) return @@ -55,16 +71,20 @@ class Button(Widget): else: xc = x + w // 2 yc = y + h // 2 - if self.shape == RECTANGLE: # rectangle + if self.shape == RECTANGLE: # rectangle display.fill_rect(x, y, w, h, self.bgcolor) display.rect(x, y, w, h, self.fgcolor) if len(self.text): - display.print_centred(self.writer, xc, yc, self.text, self.textcolor, self.bgcolor) - elif self.shape == CLIPPED_RECT: # clipped rectangle + display.print_centred( + self.writer, xc, yc, self.text, self.textcolor, self.bgcolor + ) + elif self.shape == CLIPPED_RECT: # clipped rectangle display.fill_clip_rect(x, y, w, h, self.bgcolor) display.clip_rect(x, y, w, h, self.fgcolor) if len(self.text): - display.print_centred(self.writer, xc, yc, self.text, self.textcolor, self.bgcolor) + display.print_centred( + self.writer, xc, yc, self.text, self.textcolor, self.bgcolor + ) async def shownormal(self): # Handle case where screen changed while timer was active: delay repaint @@ -75,13 +95,14 @@ class Button(Widget): self.bgcolor = self.def_bgcolor self.draw = True # Redisplay - def do_sel(self): # Select was pushed - self.callback(self, *self.callback_args) # CB takes self as 1st arg. + def do_sel(self): # Select was pushed + self.callback(self, *self.callback_args) # CB takes self as 1st arg. if self.litcolor is not None and self.has_focus(): # CB may have changed focus self.bgcolor = self.litcolor self.draw = True # Redisplay self.delay.trigger(Button.lit_time) + # Preferred way to close a screen or dialog. Produces an X button at the top RHS. # Note that if the bottom screen is closed, the application terminates. class CloseButton(Button): @@ -89,30 +110,38 @@ class CloseButton(Button): scr = Screen.current_screen # Calculate the button width if not provided. Button allows # 5 pixels either side. - wd = width if width else (writer.stringlen('X') + 10) + wd = width if width else (writer.stringlen("X") + 10) self.user_cb = callback self.user_args = args - super().__init__(writer, *scr.locn(4, scr.width - wd - 4), - width = wd, height = wd, bgcolor = bgcolor, - callback = self.cb, text = 'X') + super().__init__( + writer, + *scr.locn(4, scr.width - wd - 4), + width=wd, + height=wd, + bgcolor=bgcolor, + callback=self.cb, + text="X" + ) def cb(self, _): self.user_cb(self, *self.user_args) Screen.back() - + + # Group of buttons, typically at same location, where pressing one shows # the next e.g. start/stop toggle or sequential select from short list class ButtonList: - def __init__(self, callback=dolittle): + def __init__(self, callback=dolittle, new_cb=False): self.user_callback = callback + self._new_cb = new_cb self.lstbuttons = [] - self.current = None # No current button + self.current = None # No current button self._greyed_out = False def add_button(self, *args, **kwargs): button = Button(*args, **kwargs) self.lstbuttons.append(button) - active = self.current is None # 1st button added is active + active = self.current is None # 1st button added is active button.visible = active button.callback = self._callback if active: @@ -128,12 +157,12 @@ class ButtonList: new.visible = True new.draw = True # Redisplay without changing currency # Args for user callback: button instance followed by any specified. - # Normal behaviour is to run cb of old button: this mimics a button press. - # Optionally programmatic value changes can run the cb of new button. - if new_cb: # Forced value change, callback is that of new button + # Normal behaviour is to run cb of old button (see docs). + # This may be overridden for programmatic value changes, or for + # physical button presses via constructor arg. See docs. + if new_cb or self._new_cb: self.user_callback(new, *new.callback_args) - else: # A button was pressed - # Callback context is button just pressed, not the new one + else: self.user_callback(old, *old.callback_args) return self.current @@ -153,8 +182,10 @@ class ButtonList: old.visible = False new.visible = True Screen.select(new) # Move currency and redisplay - # Callback context is button just pressed, not the new one - self.user_callback(old, *old.callback_args) + if self._new_cb: + self.user_callback(new, *new.callback_args) + else: + self.user_callback(old, *old.callback_args) # Group of buttons at different locations, where pressing one shows @@ -163,7 +194,7 @@ class RadioButtons: def __init__(self, highlight, callback=dolittle, selected=0): self.user_callback = callback self.lstbuttons = [] - self.current = None # No current button + self.current = None # No current button self.highlight = highlight self.selected = selected self._greyed_out = False @@ -198,4 +229,4 @@ class RadioButtons: else: but.bgcolor = but.def_bgcolor but.draw = True - self.user_callback(button, *args) # user gets button with args they specified + self.user_callback(button, *args) # user gets button with args they specified