diff --git a/src/seedsigner/gui/renderer.py b/src/seedsigner/gui/renderer.py index 9534a7747..9ec58daf5 100644 --- a/src/seedsigner/gui/renderer.py +++ b/src/seedsigner/gui/renderer.py @@ -1,8 +1,11 @@ from PIL import Image, ImageDraw from threading import Lock -from seedsigner.gui.components import Fonts, GUIConstants -from seedsigner.hardware.ST7789 import ST7789 +# from seedsigner.hardware.st7789_mpy import ST7789 +from seedsigner.hardware.displays.display_driver import ALL_DISPLAY_TYPES, DISPLAY_TYPE__ILI9341, DISPLAY_TYPE__ILI9486, DISPLAY_TYPE__ST7789, DisplayDriver +from seedsigner.hardware.displays.ili9341 import ILI9341, ILI9341_TFTWIDTH, ILI9341_TFTHEIGHT +from seedsigner.models.settings import Settings +from seedsigner.models.settings_definition import SettingsConstants from seedsigner.models.singleton import ConfigurableSingleton @@ -23,19 +26,44 @@ def configure_instance(cls): renderer = cls.__new__(cls) cls._instance = renderer - # Eventually we'll be able to plug in other display controllers - renderer.disp = ST7789() - renderer.canvas_width = renderer.disp.width - renderer.canvas_height = renderer.disp.height + renderer.initialize_display() - renderer.canvas = Image.new('RGB', (renderer.canvas_width, renderer.canvas_height)) - renderer.draw = ImageDraw.Draw(renderer.canvas) + + def initialize_display(self): + # May be called while already running with a previous display driver; must + # prevent any other screen writes while we're changing the display driver. + self.lock.acquire() + + display_config = Settings.get_instance().get_value(SettingsConstants.SETTING__DISPLAY_CONFIGURATION, default_if_none=True) + self.display_type = display_config.split("_")[0] + if self.display_type not in ALL_DISPLAY_TYPES: + raise Exception(f"Invalid display type: {self.display_type}") + + width, height = display_config.split("_")[1].split("x") + self.disp = DisplayDriver(self.display_type, width=int(width), height=int(height)) + + if Settings.get_instance().get_value(SettingsConstants.SETTING__DISPLAY_COLOR_INVERTED, default_if_none=True) == SettingsConstants.OPTION__ENABLED: + self.disp.invert() + + if self.display_type == DISPLAY_TYPE__ST7789: + self.canvas_width = self.disp.width + self.canvas_height = self.disp.height + + elif self.display_type in [DISPLAY_TYPE__ILI9341, DISPLAY_TYPE__ILI9486]: + # Swap for the natively portrait-oriented displays + self.canvas_width = self.disp.height + self.canvas_height = self.disp.width + + self.canvas = Image.new('RGB', (self.canvas_width, self.canvas_height)) + self.draw = ImageDraw.Draw(self.canvas) + + self.lock.release() def show_image(self, image=None, alpha_overlay=None, show_direct=False): if show_direct: # Use the incoming image as the canvas and immediately render - self.disp.ShowImage(image, 0, 0) + self.disp.show_image(image, 0, 0) return if alpha_overlay: @@ -47,7 +75,7 @@ def show_image(self, image=None, alpha_overlay=None, show_direct=False): # Always write to the current canvas, rather than trying to replace it self.canvas.paste(image) - self.disp.ShowImage(self.canvas, 0, 0) + self.disp.show_image(self.canvas, 0, 0) def show_image_pan(self, image, start_x, start_y, end_x, end_y, rate, alpha_overlay=None): @@ -81,7 +109,7 @@ def show_image_pan(self, image, start_x, start_y, end_x, end_y, rate, alpha_over # Always keep a copy of the current display in the canvas self.canvas.paste(crop) - self.disp.ShowImage(crop, 0, 0) + self.disp.show_image(crop, 0, 0) diff --git a/src/seedsigner/gui/screens/screen.py b/src/seedsigner/gui/screens/screen.py index a77997ab2..e5167a289 100644 --- a/src/seedsigner/gui/screens/screen.py +++ b/src/seedsigner/gui/screens/screen.py @@ -522,9 +522,11 @@ def __post_init__(self): if len(self.button_data) not in [2, 4]: raise Exception("LargeButtonScreen only supports 2 or 4 buttons") - # Maximize 2-across width; calc height with a 4:3 aspect ratio + # Maximize 2-across width button_width = int((self.canvas_width - (2 * GUIConstants.EDGE_PADDING) - GUIConstants.COMPONENT_PADDING) / 2) - button_height = int(button_width * (3.0 / 4.0)) + + # Maximize 2-row height + button_height = int((self.canvas_height - self.top_nav.height - (2 * GUIConstants.COMPONENT_PADDING) - GUIConstants.EDGE_PADDING) / 2) # Vertically center the buttons if len(self.button_data) == 2: diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index 64fb7ea98..d5c6eedff 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -30,8 +30,17 @@ def __post_init__(self): self.possible_alphabet = "abcdefghijklmnopqrstuvwxyz" + # Measure the width required to display the longest word in the English bip39 + # wordlist. + # TODO: If we ever support other wordlist languages, adjust accordingly. + matches_list_highlight_font_name = GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME + matches_list_highlight_font_size = GUIConstants.BUTTON_FONT_SIZE + 4 + (left, top, right, bottom) = Fonts.get_font(matches_list_highlight_font_name, matches_list_highlight_font_size).getbbox("mushroom", anchor="ls") + matches_list_max_text_width = right - left + matches_list_button_width = matches_list_max_text_width + 2*GUIConstants.COMPONENT_PADDING + # Set up the keyboard params - self.keyboard_width = 128 + self.keyboard_width = self.canvas_width - GUIConstants.EDGE_PADDING - matches_list_button_width text_entry_display_y = self.top_nav.height text_entry_display_height = 30 @@ -77,7 +86,7 @@ def __post_init__(self): else: self.keyboard.set_selected_key(selected_letter=self.letters[-1]) - self.matches_list_x = GUIConstants.EDGE_PADDING + self.keyboard.width + GUIConstants.COMPONENT_PADDING + self.matches_list_x = self.canvas_width - matches_list_button_width self.matches_list_y = self.top_nav.height self.highlighted_row_y = int((self.canvas_height - GUIConstants.BUTTON_HEIGHT)/2) @@ -616,11 +625,17 @@ def __post_init__(self): ) self.components.append(self.derivation_line) + font_name = GUIConstants.FIXED_WIDTH_FONT_NAME + font_size = GUIConstants.BODY_FONT_SIZE + 2 + left, top, right, bottom = Fonts.get_font(font_name, font_size).getbbox("X") + char_width = right - left + num_chars = int((self.canvas_width - GUIConstants.ICON_FONT_SIZE - 2*GUIConstants.COMPONENT_PADDING) / char_width) - 3 # ellipsis + self.xpub_line = IconTextLine( icon_name=FontAwesomeIconConstants.X, icon_color=GUIConstants.INFO_COLOR, label_text="Xpub", - value_text=f"{self.xpub[:18]}...", + value_text=f"{self.xpub[:num_chars]}...", font_name=GUIConstants.FIXED_WIDTH_FONT_NAME, font_size=GUIConstants.BODY_FONT_SIZE + 2, screen_x=GUIConstants.COMPONENT_PADDING, diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py index 13fa33beb..773cad498 100644 --- a/src/seedsigner/gui/screens/tools_screens.py +++ b/src/seedsigner/gui/screens/tools_screens.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from typing import Any from PIL.Image import Image +from seedsigner.gui.renderer import Renderer from seedsigner.hardware.camera import Camera from seedsigner.gui.components import FontAwesomeIconConstants, Fonts, GUIConstants, IconTextLine, SeedSignerIconConstants, TextArea @@ -432,3 +433,40 @@ def __post_init__(self): screen_x=GUIConstants.EDGE_PADDING, screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING, )) + + + +@dataclass +class ToolsAddressExplorerAddressListScreen(ButtonListScreen): + start_index: int = 0 + addresses: list[str] = None + next_button: tuple = None + + def __post_init__(self): + self.button_font_name = GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME + self.button_font_size = GUIConstants.BUTTON_FONT_SIZE + 4 + self.is_button_text_centered = False + self.is_bottom_list = True + + left, top, right, bottom = Fonts.get_font(self.button_font_name, self.button_font_size).getbbox("X") + char_width = right - left + + last_index = self.start_index + len(self.addresses) - 1 + index_digits = len(str(last_index)) + + # Calculate how many pixels we have available within each address button, + # remembering to account for the index number that will be displayed. + # Note: because we haven't called the parent's post_init yet, we don't have a + # self.canvas_width set; have to use the Renderer singleton to get it. + available_width = Renderer.get_instance().canvas_width - 2*GUIConstants.EDGE_PADDING - 2*GUIConstants.COMPONENT_PADDING - (index_digits + 1)*char_width + displayable_chars = int(available_width / char_width) - 3 # ellipsis + displayable_half = int(displayable_chars/2) + + self.button_data = [] + for i, address in enumerate(self.addresses): + cur_index = i + self.start_index + self.button_data.append(f"{cur_index}:{address[:displayable_half]}...{address[-1*displayable_half:]}") + + self.button_data.append(self.next_button) + + super().__post_init__() diff --git a/src/seedsigner/hardware/ST7789.py b/src/seedsigner/hardware/ST7789.py deleted file mode 100644 index ebd4952ba..000000000 --- a/src/seedsigner/hardware/ST7789.py +++ /dev/null @@ -1,169 +0,0 @@ -import spidev -import RPi.GPIO as GPIO -import time -import array - - - -class ST7789(object): - """class for ST7789 240*240 1.3inch OLED displays.""" - - def __init__(self): - self.width = 240 - self.height = 240 - - #Initialize DC RST pin - self._dc = 22 - self._rst = 13 - self._bl = 18 - - GPIO.setmode(GPIO.BOARD) - GPIO.setwarnings(False) - GPIO.setup(self._dc,GPIO.OUT) - GPIO.setup(self._rst,GPIO.OUT) - GPIO.setup(self._bl,GPIO.OUT) - GPIO.output(self._bl, GPIO.HIGH) - - #Initialize SPI - self._spi = spidev.SpiDev(0, 0) - self._spi.max_speed_hz = 40000000 - - self.init() - - - """ Write register address and data """ - def command(self, cmd): - GPIO.output(self._dc, GPIO.LOW) - self._spi.writebytes([cmd]) - - def data(self, val): - GPIO.output(self._dc, GPIO.HIGH) - self._spi.writebytes([val]) - - def init(self): - """Initialize dispaly""" - self.reset() - - self.command(0x36) - self.data(0x70) #self.data(0x00) - - self.command(0x3A) - self.data(0x05) - - self.command(0xB2) - self.data(0x0C) - self.data(0x0C) - self.data(0x00) - self.data(0x33) - self.data(0x33) - - self.command(0xB7) - self.data(0x35) - - self.command(0xBB) - self.data(0x19) - - self.command(0xC0) - self.data(0x2C) - - self.command(0xC2) - self.data(0x01) - - self.command(0xC3) - self.data(0x12) - - self.command(0xC4) - self.data(0x20) - - self.command(0xC6) - self.data(0x0F) - - self.command(0xD0) - self.data(0xA4) - self.data(0xA1) - - self.command(0xE0) - self.data(0xD0) - self.data(0x04) - self.data(0x0D) - self.data(0x11) - self.data(0x13) - self.data(0x2B) - self.data(0x3F) - self.data(0x54) - self.data(0x4C) - self.data(0x18) - self.data(0x0D) - self.data(0x0B) - self.data(0x1F) - self.data(0x23) - - self.command(0xE1) - self.data(0xD0) - self.data(0x04) - self.data(0x0C) - self.data(0x11) - self.data(0x13) - self.data(0x2C) - self.data(0x3F) - self.data(0x44) - self.data(0x51) - self.data(0x2F) - self.data(0x1F) - self.data(0x1F) - self.data(0x20) - self.data(0x23) - - self.command(0x21) - - self.command(0x11) - - self.command(0x29) - - def reset(self): - """Reset the display""" - GPIO.output(self._rst,GPIO.HIGH) - time.sleep(0.01) - GPIO.output(self._rst,GPIO.LOW) - time.sleep(0.01) - GPIO.output(self._rst,GPIO.HIGH) - time.sleep(0.01) - - def SetWindows(self, Xstart, Ystart, Xend, Yend): - #set the X coordinates - self.command(0x2A) - self.data(0x00) #Set the horizontal starting point to the high octet - self.data(Xstart & 0xff) #Set the horizontal starting point to the low octet - self.data(0x00) #Set the horizontal end to the high octet - self.data((Xend - 1) & 0xff) #Set the horizontal end to the low octet - - #set the Y coordinates - self.command(0x2B) - self.data(0x00) - self.data((Ystart & 0xff)) - self.data(0x00) - self.data((Yend - 1) & 0xff ) - - self.command(0x2C) - - def ShowImage(self,Image,Xstart,Ystart): - """Set buffer to value of Python Imaging Library image.""" - """Write display buffer to physical display""" - imwidth, imheight = Image.size - if imwidth != self.width or imheight != self.height: - raise ValueError('Image must be same dimensions as display \ - ({0}x{1}).' .format(self.width, self.height)) - # convert 24-bit RGB-8:8:8 to gBRG-3:5:5:3; then per-pixel byteswap to 16-bit RGB-5:6:5 - arr = array.array("H", Image.convert("BGR;16").tobytes()) - arr.byteswap() - pix = arr.tobytes() - self.SetWindows ( 0, 0, self.width, self.height) - GPIO.output(self._dc,GPIO.HIGH) - self._spi.writebytes2(pix) - - def clear(self): - """Clear contents of image buffer""" - _buffer = [0xff]*(self.width * self.height * 2) - self.SetWindows ( 0, 0, self.width, self.height) - GPIO.output(self._dc,GPIO.HIGH) - self._spi.writebytes2(_buffer) diff --git a/src/seedsigner/hardware/displays/__init__.py b/src/seedsigner/hardware/displays/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/seedsigner/hardware/displays/display_driver.py b/src/seedsigner/hardware/displays/display_driver.py new file mode 100644 index 000000000..5421abfbd --- /dev/null +++ b/src/seedsigner/hardware/displays/display_driver.py @@ -0,0 +1,46 @@ +DISPLAY_TYPE__ST7789 = "st7789" +DISPLAY_TYPE__ILI9341 = "ili9341" +DISPLAY_TYPE__ILI9486 = "ili9486" + +ALL_DISPLAY_TYPES = [DISPLAY_TYPE__ST7789, DISPLAY_TYPE__ILI9341, DISPLAY_TYPE__ILI9486] + + +class DisplayDriver: + def __init__(self, display_type: str = DISPLAY_TYPE__ST7789, width: int = None, height: int = None): + if display_type not in ALL_DISPLAY_TYPES: + raise ValueError(f"Invalid display type: {display_type}") + self.display_type = display_type + + if self.display_type == DISPLAY_TYPE__ST7789: + from seedsigner.hardware.displays.st7789_mpy import ST7789 + if height not in [240, 320] or width != 240: + raise ValueError("ST7789 display only supports 240x240 or 320x240 resolutions") + self.display = ST7789(width=width, height=height) + + elif self.display_type == DISPLAY_TYPE__ILI9341: + from seedsigner.hardware.displays.ili9341 import ILI9341 + self.display = ILI9341() + self.display.begin() + + elif self.display_type == DISPLAY_TYPE__ILI9486: + # TODO: improve performance of ili9486 driver + raise Exception("ILI9486 display not implemented yet") + + + @property + def width(self): + return self.display.width + + + @property + def height(self): + return self.display.height + + + def invert(self, enabled: bool = True): + """Invert how the display interprets colors""" + self.display.invert(enabled) + + + def show_image(self, image, x_start: int = 0, y_start: int = 0): + self.display.show_image(image, x_start, y_start) \ No newline at end of file diff --git a/src/seedsigner/hardware/displays/ili9341.py b/src/seedsigner/hardware/displays/ili9341.py new file mode 100644 index 000000000..1330aa7c8 --- /dev/null +++ b/src/seedsigner/hardware/displays/ili9341.py @@ -0,0 +1,376 @@ +# Copyright (c) 2014 Adafruit Industries +# Author: Tony DiCola +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation 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 +# furnished 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 FOR 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. +""" +Tested with a 320x240 IPS display (https://a.co/d/2Q9wDLo) +* Framerate is excellent (~10fps) +* Requires `invert()` and 90° rotation +* Exhibits noticeable residual ghosting +""" +import numbers +import time +# import numpy as np +import array + +from PIL import Image +from PIL import ImageDraw + +import RPi.GPIO as GPIO +from spidev import SpiDev + + +# Constants for interacting with display registers. +ILI9341_TFTWIDTH = 240 +ILI9341_TFTHEIGHT = 320 + +ILI9341_NOP = 0x00 +ILI9341_SWRESET = 0x01 +ILI9341_RDDID = 0x04 +ILI9341_RDDST = 0x09 + +ILI9341_SLPIN = 0x10 +ILI9341_SLPOUT = 0x11 +ILI9341_PTLON = 0x12 +ILI9341_NORON = 0x13 + +ILI9341_RDMODE = 0x0A +ILI9341_RDMADCTL = 0x0B +ILI9341_RDPIXFMT = 0x0C +ILI9341_RDIMGFMT = 0x0A +ILI9341_RDSELFDIAG = 0x0F + +ILI9341_INVOFF = 0x20 +ILI9341_INVON = 0x21 +ILI9341_GAMMASET = 0x26 +ILI9341_DISPOFF = 0x28 +ILI9341_DISPON = 0x29 + +ILI9341_CASET = 0x2A +ILI9341_PASET = 0x2B +ILI9341_RAMWR = 0x2C +ILI9341_RAMRD = 0x2E + +ILI9341_PTLAR = 0x30 +ILI9341_MADCTL = 0x36 +ILI9341_PIXFMT = 0x3A + +ILI9341_FRMCTR1 = 0xB1 +ILI9341_FRMCTR2 = 0xB2 +ILI9341_FRMCTR3 = 0xB3 +ILI9341_INVCTR = 0xB4 +ILI9341_DFUNCTR = 0xB6 + +ILI9341_PWCTR1 = 0xC0 +ILI9341_PWCTR2 = 0xC1 +ILI9341_PWCTR3 = 0xC2 +ILI9341_PWCTR4 = 0xC3 +ILI9341_PWCTR5 = 0xC4 +ILI9341_VMCTR1 = 0xC5 +ILI9341_VMCTR2 = 0xC7 + +ILI9341_RDID1 = 0xDA +ILI9341_RDID2 = 0xDB +ILI9341_RDID3 = 0xDC +ILI9341_RDID4 = 0xDD + +ILI9341_GMCTRP1 = 0xE0 +ILI9341_GMCTRN1 = 0xE1 + +ILI9341_PWCTR6 = 0xFC + +ILI9341_BLACK = 0x0000 +ILI9341_BLUE = 0x001F +ILI9341_RED = 0xF800 +ILI9341_GREEN = 0x07E0 +ILI9341_CYAN = 0x07FF +ILI9341_MAGENTA = 0xF81F +ILI9341_YELLOW = 0xFFE0 +ILI9341_WHITE = 0xFFFF + + +def color565(r, g, b): + """Convert red, green, blue components to a 16-bit 565 RGB value. Components + should be values 0 to 255. + """ + return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3) + +def image_to_data(image): + """Generator function to convert a PIL image to 16-bit 565 RGB bytes.""" + #NumPy is much faster at doing this. NumPy code provided by: + #Keith (https://www.blogger.com/profile/02555547344016007163) + # pb = np.array(image.convert('RGB')).astype('uint16') + # color = ((pb[:,:,0] & 0xF8) << 8) | ((pb[:,:,1] & 0xFC) << 3) | (pb[:,:,2] >> 3) + # return np.dstack(((color >> 8) & 0xFF, color & 0xFF)).flatten().tolist() + + # convert 24-bit RGB-8:8:8 to gBRG-3:5:5:3 ("BGR;16"): + # 3 highest bits of green + 5 highest bits of blue in the first byte and + # 5 highest bits of red + the next 3 highest bits of green (not yet expressed) in the second byte. + # Then per-pixel byteswap to 16-bit RGB-5:6:5 + # This approach was measured to be ~3.4x faster than the numpy code above. + arr = array.array("H", image.convert("BGR;16").tobytes()) + arr.byteswap() + return arr.tobytes() + + +class ILI9341(object): + """Representation of an ILI9341 TFT LCD.""" + + def __init__(self, dc=22, rst=13, led=12, width=ILI9341_TFTWIDTH, + height=ILI9341_TFTHEIGHT, rotation=90): + """Create an instance of the display using SPI communication. Must + provide the GPIO pin number for the D/C pin and the SPI driver. Can + optionally provide the GPIO pin number for the reset pin as the rst + parameter. + """ + spi = SpiDev(0, 0) + # spi.mode = 0b10 # [CPOL|CPHA] -> polarity 1, phase 0 + spi.max_speed_hz = 64_000_000 + + self._dc = dc + self._rst = rst + self._spi = spi + self.width = width + self.height = height + self.rotation = rotation + self.inverted = False + # if self._gpio is None: + # self._gpio = GPIO.get_platform_gpio() + # Set DC as output. + + GPIO.setmode(GPIO.BOARD) # Use physical pin nums, not gpio labels + GPIO.setwarnings(False) + GPIO.setup(self._dc, GPIO.OUT) + GPIO.output(self._dc, GPIO.HIGH) + GPIO.setup(led, GPIO.OUT) + GPIO.output(led, GPIO.HIGH) + if self._rst is not None: + GPIO.setup(self._rst, GPIO.OUT) + GPIO.output(self._rst, GPIO.HIGH) + + # Create an image buffer. + self.buffer = Image.new('RGB', (width, height)) + + # @property + # def width(self): + # return self.width + + # @property + # def height(self): + # return self.height + + + def send(self, data, is_data=True, chunk_size=4096): + """Write a byte or array of bytes to the display. Is_data parameter + controls if byte should be interpreted as display data (True) or command + data (False). Chunk_size is an optional size of bytes to write in a + single SPI transaction, with a default of 4096. + """ + # Set DC low for command, high for data. + GPIO.output(self._dc, is_data) + # Convert scalar argument to list so either can be passed as parameter. + if isinstance(data, numbers.Number): + data = [data & 0xFF] + # Write data a chunk at a time. + # for start in range(0, len(data), chunk_size): + # end = min(start+chunk_size, len(data)) + # self._spi.writebytes2(data[start:end]) + + self._spi.writebytes2(data) + + def command(self, data): + """Write a byte or array of bytes to the display as command data.""" + self.send(data, False) + + def data(self, data): + """Write a byte or array of bytes to the display as display data.""" + self.send(data, True) + + def reset(self): + """Reset the display, if reset pin is connected.""" + if self._rst is not None: + GPIO.output(self._rst, GPIO.HIGH) + time.sleep(0.005) + GPIO.output(self._rst, GPIO.LOW) + time.sleep(0.02) + GPIO.output(self._rst, GPIO.HIGH) + time.sleep(0.150) + + def _init(self): + # Initialize the display. Broken out as a separate function so it can + # be overridden by other displays in the future. + self.command(0xEF) + self.data(0x03) + self.data(0x80) + self.data(0x02) + self.command(0xCF) + self.data(0x00) + self.data(0XC1) + self.data(0X30) + self.command(0xED) + self.data(0x64) + self.data(0x03) + self.data(0X12) + self.data(0X81) + self.command(0xE8) + self.data(0x85) + self.data(0x00) + self.data(0x78) + self.command(0xCB) + self.data(0x39) + self.data(0x2C) + self.data(0x00) + self.data(0x34) + self.data(0x02) + self.command(0xF7) + self.data(0x20) + self.command(0xEA) + self.data(0x00) + self.data(0x00) + self.command(ILI9341_PWCTR1) # Power control + self.data(0x23) # VRH[5:0] + self.command(ILI9341_PWCTR2) # Power control + self.data(0x10) # SAP[2:0];BT[3:0] + self.command(ILI9341_VMCTR1) # VCM control + self.data(0x3e) + self.data(0x28) + self.command(ILI9341_VMCTR2) # VCM control2 + self.data(0x86) # -- + self.command(ILI9341_MADCTL) # Memory Access Control + self.data(0x48) + self.command(ILI9341_PIXFMT) + self.data(0x55) + self.command(ILI9341_FRMCTR1) + self.data(0x00) + self.data(0x18) + self.command(ILI9341_DFUNCTR) # Display Function Control + self.data(0x08) + self.data(0x82) + self.data(0x27) + self.command(0xF2) # 3Gamma Function Disable + self.data(0x00) + self.command(ILI9341_GAMMASET) # Gamma curve selected + self.data(0x01) + self.command(ILI9341_GMCTRP1) # Set Gamma + self.data(0x0F) + self.data(0x31) + self.data(0x2B) + self.data(0x0C) + self.data(0x0E) + self.data(0x08) + self.data(0x4E) + self.data(0xF1) + self.data(0x37) + self.data(0x07) + self.data(0x10) + self.data(0x03) + self.data(0x0E) + self.data(0x09) + self.data(0x00) + self.command(ILI9341_GMCTRN1) # Set Gamma + self.data(0x00) + self.data(0x0E) + self.data(0x14) + self.data(0x03) + self.data(0x11) + self.data(0x07) + self.data(0x31) + self.data(0xC1) + self.data(0x48) + self.data(0x08) + self.data(0x0F) + self.data(0x0C) + self.data(0x31) + self.data(0x36) + self.data(0x0F) + self.command(ILI9341_SLPOUT) # Exit Sleep + time.sleep(0.120) + self.command(ILI9341_DISPON) # Display on + + def begin(self): + """Initialize the display. Should be called once before other calls that + interact with the display are called. + """ + self.reset() + self._init() + + def invert(self, state: bool = True): + """Sets display inversion to the specified state. If not provided, state + is True, which inverts the display. If state is False, the display turns + back into normal mode.""" + if state: + self.command(ILI9341_INVON) + else: + self.command(ILI9341_INVOFF) + self.inverted = state + return self + + def set_window(self, x0=0, y0=0, x1=None, y1=None): + """Set the pixel address window for proceeding drawing commands. x0 and + x1 should define the minimum and maximum x pixel bounds. y0 and y1 + should define the minimum and maximum y pixel bound. If no parameters + are specified the default will be to update the entire display from 0,0 + to 239,319. + """ + if x1 is None: + x1 = self.width-1 + if y1 is None: + y1 = self.height-1 + self.command(ILI9341_CASET) # Column addr set + self.data(x0 >> 8) + self.data(x0) # XSTART + self.data(x1 >> 8) + self.data(x1) # XEND + self.command(ILI9341_PASET) # Row addr set + self.data(y0 >> 8) + self.data(y0) # YSTART + self.data(y1 >> 8) + self.data(y1) # YEND + self.command(ILI9341_RAMWR) # write to RAM + + def show_image(self, image=None, x_start: int = 0, y_start: int = 0): + """Write the display buffer or provided image to the hardware. If no + image parameter is provided the display buffer will be written to the + hardware. If an image is provided, it should be RGB format and the + same dimensions as the display hardware. + """ + # By default write the internal buffer to the display. + if image is None: + image = self.buffer + + output_image = image.rotate(self.rotation, expand=True) + self.set_window(x_start, y_start, x_start + output_image.width - 1, y_start + output_image.height - 1) + + # Convert image to array of 16bit 565 RGB data bytes. + # Unfortunate that this copy has to occur, but the SPI byte writing + # function needs to take an array of bytes and PIL doesn't natively + # store images in 16-bit 565 RGB format. + pixelbytes = image_to_data(output_image) + + # Write data to hardware. + self.data(pixelbytes) + + def clear(self, color=(0,0,0)): + """Clear the image buffer to the specified RGB color (default black).""" + width, height = self.buffer.size + self.buffer.putdata([color]*(width*height)) + + def draw(self): + """Return a PIL ImageDraw instance for 2D drawing on the image buffer.""" + return ImageDraw.Draw(self.buffer) \ No newline at end of file diff --git a/src/seedsigner/hardware/displays/st7789_mpy.py b/src/seedsigner/hardware/displays/st7789_mpy.py new file mode 100644 index 000000000..5a7bd5f82 --- /dev/null +++ b/src/seedsigner/hardware/displays/st7789_mpy.py @@ -0,0 +1,1033 @@ +""" +MIT License + +Copyright (c) 2020-2023 Russ Hughes + +Copyright (c) 2019 Ivan Belokobylskiy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation 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 +furnished 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 FOR 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. + +The driver is based on devbis' st7789py_mpy module from +https://github.com/devbis/st7789py_mpy. + +This driver supports: + +- 320x240, 240x240, 135x240 and 128x128 pixel displays +- Display rotation +- RGB and BGR color orders +- Hardware based scrolling +- Drawing text using 8 and 16 bit wide bitmap fonts with heights that are + multiples of 8. Included are 12 bitmap fonts derived from classic pc + BIOS text mode fonts. +- Drawing text using converted TrueType fonts. +- Drawing converted bitmaps +- Named color constants + + - BLACK + - BLUE + - RED + - GREEN + - CYAN + - MAGENTA + - YELLOW + - WHITE + +""" + +import array +import spidev +import RPi.GPIO as GPIO + +from math import sin, cos + +# +# This allows sphinx to build the docs +# + +try: + from time import sleep_ms +except ImportError: + sleep_ms = lambda ms: None + uint = int + const = lambda x: x + + class micropython: + @staticmethod + def viper(func): + return func + + @staticmethod + def native(func): + return func + + +# +# If you don't need to build the docs, you can remove all of the lines between +# here and the comment above except for the "from time import sleep_ms" line. +# + +import struct + +# ST7789 commands +_ST7789_SWRESET = b"\x01" +_ST7789_SLPIN = b"\x10" +_ST7789_SLPOUT = b"\x11" +_ST7789_NORON = b"\x13" +_ST7789_INVOFF = b"\x20" +_ST7789_INVON = b"\x21" +_ST7789_DISPOFF = b"\x28" +_ST7789_DISPON = b"\x29" +_ST7789_CASET = b"\x2a" +_ST7789_RASET = b"\x2b" +_ST7789_RAMWR = b"\x2c" +_ST7789_VSCRDEF = b"\x33" +_ST7789_COLMOD = b"\x3a" +_ST7789_MADCTL = b"\x36" +_ST7789_VSCSAD = b"\x37" +_ST7789_RAMCTL = b"\xb0" + +# MADCTL bits +_ST7789_MADCTL_MY = const(0x80) +_ST7789_MADCTL_MX = const(0x40) +_ST7789_MADCTL_MV = const(0x20) +_ST7789_MADCTL_ML = const(0x10) +_ST7789_MADCTL_BGR = const(0x08) +_ST7789_MADCTL_MH = const(0x04) +_ST7789_MADCTL_RGB = const(0x00) + +RGB = 0x00 +BGR = 0x08 + +# Color modes +_COLOR_MODE_65K = const(0x50) +_COLOR_MODE_262K = const(0x60) +_COLOR_MODE_12BIT = const(0x03) +_COLOR_MODE_16BIT = const(0x05) +_COLOR_MODE_18BIT = const(0x06) +_COLOR_MODE_16M = const(0x07) + +# Color definitions +BLACK = const(0x0000) +BLUE = const(0x001F) +RED = const(0xF800) +GREEN = const(0x07E0) +CYAN = const(0x07FF) +MAGENTA = const(0xF81F) +YELLOW = const(0xFFE0) +WHITE = const(0xFFFF) + +_ENCODE_PIXEL = const(">H") +_ENCODE_PIXEL_SWAPPED = const("HH") +_ENCODE_POS_16 = const("> 3 + + +class ST7789: + """ + ST7789 driver class + + Args: + spi (spi): spi object **Required** + width (int): display width **Required** + height (int): display height **Required** + reset (pin): reset pin + dc (pin): dc pin **Required** + cs (pin): cs pin + backlight(pin): backlight pin + rotation (int): + + - 0-Portrait + - 1-Landscape + - 2-Inverted Portrait + - 3-Inverted Landscape + + color_order (int): + + - RGB: Red, Green Blue, default + - BGR: Blue, Green, Red + + custom_init (tuple): custom initialization commands + + - ((b'command', b'data', delay_ms), ...) + + custom_rotations (tuple): custom rotation definitions + + - ((width, height, xstart, ystart, madctl, needs_swap), ...) + + """ + + def __init__( + self, + # spi, + width, + height, + reset=13, + dc=22, + cs=None, + backlight=18, + rotation=1, + color_order=BGR, + custom_init=None, + custom_rotations=None, + ): + + GPIO.setmode(GPIO.BOARD) + GPIO.setwarnings(False) + GPIO.setup(dc,GPIO.OUT) + GPIO.setup(reset,GPIO.OUT) + GPIO.setup(backlight,GPIO.OUT) + + #Initialize SPI + spi = spidev.SpiDev(0, 0) + spi.max_speed_hz = 40000000 + + """ + Initialize display. + """ + self.rotations = custom_rotations or self._find_rotations(width, height) + if not self.rotations: + supported_displays = ", ".join( + [f"{display[0]}x{display[1]}" for display in _SUPPORTED_DISPLAYS] + ) + raise ValueError( + f"Unsupported {width}x{height} display. Supported displays: {supported_displays}" + ) + + if dc is None: + raise ValueError("dc pin is required.") + + self.physical_width = self.width = width + self.physical_height = self.height = height + self.xstart = 0 + self.ystart = 0 + self.spi = spi + self.reset = reset + self.dc = dc + self.cs = cs + self.backlight = backlight + self._rotation = rotation % 4 + self.color_order = color_order + self.init_cmds = custom_init or _ST7789_INIT_CMDS + self.hard_reset() + # yes, twice, once is not always enough + self.init(self.init_cmds) + self.init(self.init_cmds) + self.rotation(self._rotation) + self.needs_swap = False + self.fill(0x0) + + if backlight is not None: + GPIO.output(backlight, GPIO.HIGH) + # backlight.value(1) + + @staticmethod + def _find_rotations(width, height): + for display in _SUPPORTED_DISPLAYS: + if display[0] == width and display[1] == height: + return display[2] + return None + + def init(self, commands): + """ + Initialize display. + """ + for command, data, delay in commands: + self._write(command, data) + sleep_ms(delay) + + def invert(self, enabled: bool = True): + self.inversion_mode(enabled) + + def show_image(self, image, x_start: int = 0, y_start: int = 0): + """Set buffer to value of Python Imaging Library image.""" + """Write display buffer to physical display""" + + # image = image.rotate(90, expand=True) + + imwidth, imheight = image.size + if imwidth != self.width or imheight != self.height: + raise ValueError('Image must be same dimensions as display \ + ({0}x{1}).' .format(self.width, self.height)) + + # convert 24-bit RGB-8:8:8 to gBRG-3:5:5:3; then per-pixel byteswap to 16-bit RGB-5:6:5 + arr = array.array("H", image.convert("BGR;16").tobytes()) + arr.byteswap() + pix = arr.tobytes() + + self._set_window(x_start, y_start, self.width, self.height) + GPIO.output(self.dc,GPIO.HIGH) + self._write(data=pix) + + def _write(self, command=None, data=None): + """SPI write to the device: commands and data.""" + if self.cs: + GPIO.output(self.cs, GPIO.LOW) + if command is not None: + GPIO.output(self.dc, GPIO.LOW) + self.spi.writebytes2(command) + if data is not None: + GPIO.output(self.dc,GPIO.HIGH) + self.spi.writebytes2(data) + if self.cs: + GPIO.output(self.cs,GPIO.HIGH) + + def hard_reset(self): + """ + Hard reset display. + """ + if self.cs: + GPIO.output(self.cs, GPIO.LOW) + if self.reset: + GPIO.output(self.reset, GPIO.HIGH) + sleep_ms(10) + if self.reset: + GPIO.output(self.reset, GPIO.LOW) + sleep_ms(10) + if self.reset: + GPIO.output(self.reset, GPIO.HIGH) + sleep_ms(120) + if self.cs: + GPIO.output(self.cs, GPIO.HIGH) + + def soft_reset(self): + """ + Soft reset display. + """ + self._write(_ST7789_SWRESET) + sleep_ms(150) + + def sleep_mode(self, value): + """ + Enable or disable display sleep mode. + + Args: + value (bool): if True enable sleep mode. if False disable sleep + mode + """ + if value: + self._write(_ST7789_SLPIN) + else: + self._write(_ST7789_SLPOUT) + + def inversion_mode(self, value): + """ + Enable or disable display inversion mode. + + Args: + value (bool): if True enable inversion mode. if False disable + inversion mode + """ + if value: + self._write(_ST7789_INVON) + else: + self._write(_ST7789_INVOFF) + + def rotation(self, rotation): + """ + Set display rotation. + + Args: + rotation (int): + - 0-Portrait + - 1-Landscape + - 2-Inverted Portrait + - 3-Inverted Landscape + + custom_rotations can have any number of rotations + """ + rotation %= len(self.rotations) + self._rotation = rotation + ( + madctl, + self.width, + self.height, + self.xstart, + self.ystart, + self.needs_swap, + ) = self.rotations[rotation] + + if self.color_order == BGR: + madctl |= _ST7789_MADCTL_BGR + else: + madctl &= ~_ST7789_MADCTL_BGR + + self._write(_ST7789_MADCTL, bytes([madctl])) + + def _set_window(self, x0, y0, x1, y1): + """ + Set window to column and row address. + + Args: + x0 (int): column start address + y0 (int): row start address + x1 (int): column end address + y1 (int): row end address + """ + if x0 <= x1 <= self.width and y0 <= y1 <= self.height: + self._write( + _ST7789_CASET, + struct.pack(_ENCODE_POS, x0 + self.xstart, x1 + self.xstart), + ) + self._write( + _ST7789_RASET, + struct.pack(_ENCODE_POS, y0 + self.ystart, y1 + self.ystart), + ) + self._write(_ST7789_RAMWR) + + def vline(self, x, y, length, color): + """ + Draw vertical line at the given location and color. + + Args: + x (int): x coordinate + Y (int): y coordinate + length (int): length of line + color (int): 565 encoded color + """ + self.fill_rect(x, y, 1, length, color) + + def hline(self, x, y, length, color): + """ + Draw horizontal line at the given location and color. + + Args: + x (int): x coordinate + Y (int): y coordinate + length (int): length of line + color (int): 565 encoded color + """ + self.fill_rect(x, y, length, 1, color) + + def pixel(self, x, y, color): + """ + Draw a pixel at the given location and color. + + Args: + x (int): x coordinate + Y (int): y coordinate + color (int): 565 encoded color + """ + self._set_window(x, y, x, y) + self._write( + None, + struct.pack( + _ENCODE_PIXEL_SWAPPED if self.needs_swap else _ENCODE_PIXEL, color + ), + ) + + def blit_buffer(self, buffer, x, y, width, height): + """ + Copy buffer to display at the given location. + + Args: + buffer (bytes): Data to copy to display + x (int): Top left corner x coordinate + Y (int): Top left corner y coordinate + width (int): Width + height (int): Height + """ + self._set_window(x, y, x + width - 1, y + height - 1) + self._write(None, buffer) + + def rect(self, x, y, w, h, color): + """ + Draw a rectangle at the given location, size and color. + + Args: + x (int): Top left corner x coordinate + y (int): Top left corner y coordinate + width (int): Width in pixels + height (int): Height in pixels + color (int): 565 encoded color + """ + self.hline(x, y, w, color) + self.vline(x, y, h, color) + self.vline(x + w - 1, y, h, color) + self.hline(x, y + h - 1, w, color) + + def fill_rect(self, x, y, width, height, color): + """ + Draw a rectangle at the given location, size and filled with color. + + Args: + x (int): Top left corner x coordinate + y (int): Top left corner y coordinate + width (int): Width in pixels + height (int): Height in pixels + color (int): 565 encoded color + """ + self._set_window(x, y, x + width - 1, y + height - 1) + chunks, rest = divmod(width * height, _BUFFER_SIZE) + pixel = struct.pack( + _ENCODE_PIXEL_SWAPPED if self.needs_swap else _ENCODE_PIXEL, color + ) + GPIO.output(self.dc,GPIO.HIGH) + if chunks: + data = pixel * _BUFFER_SIZE + for _ in range(chunks): + self._write(None, data) + if rest: + self._write(None, pixel * rest) + + def fill(self, color): + """ + Fill the entire FrameBuffer with the specified color. + + Args: + color (int): 565 encoded color + """ + self.fill_rect(0, 0, self.width, self.height, color) + + def line(self, x0, y0, x1, y1, color): + """ + Draw a single pixel wide line starting at x0, y0 and ending at x1, y1. + + Args: + x0 (int): Start point x coordinate + y0 (int): Start point y coordinate + x1 (int): End point x coordinate + y1 (int): End point y coordinate + color (int): 565 encoded color + """ + steep = abs(y1 - y0) > abs(x1 - x0) + if steep: + x0, y0 = y0, x0 + x1, y1 = y1, x1 + if x0 > x1: + x0, x1 = x1, x0 + y0, y1 = y1, y0 + dx = x1 - x0 + dy = abs(y1 - y0) + err = dx // 2 + ystep = 1 if y0 < y1 else -1 + while x0 <= x1: + if steep: + self.pixel(y0, x0, color) + else: + self.pixel(x0, y0, color) + err -= dy + if err < 0: + y0 += ystep + err += dx + x0 += 1 + + def vscrdef(self, tfa, vsa, bfa): + """ + Set Vertical Scrolling Definition. + + To scroll a 135x240 display these values should be 40, 240, 40. + There are 40 lines above the display that are not shown followed by + 240 lines that are shown followed by 40 more lines that are not shown. + You could write to these areas off display and scroll them into view by + changing the TFA, VSA and BFA values. + + Args: + tfa (int): Top Fixed Area + vsa (int): Vertical Scrolling Area + bfa (int): Bottom Fixed Area + """ + self._write(_ST7789_VSCRDEF, struct.pack(">HHH", tfa, vsa, bfa)) + + def vscsad(self, vssa): + """ + Set Vertical Scroll Start Address of RAM. + + Defines which line in the Frame Memory will be written as the first + line after the last line of the Top Fixed Area on the display + + Example: + + for line in range(40, 280, 1): + tft.vscsad(line) + utime.sleep(0.01) + + Args: + vssa (int): Vertical Scrolling Start Address + + """ + self._write(_ST7789_VSCSAD, struct.pack(">H", vssa)) + + # @micropython.viper + # @staticmethod + # def _pack8(glyphs, idx: uint, fg_color: uint, bg_color: uint): + # buffer = bytearray(128) + # bitmap = ptr16(buffer) + # glyph = ptr8(glyphs) + + # for i in range(0, 64, 8): + # byte = glyph[idx] + # bitmap[i] = fg_color if byte & _BIT7 else bg_color + # bitmap[i + 1] = fg_color if byte & _BIT6 else bg_color + # bitmap[i + 2] = fg_color if byte & _BIT5 else bg_color + # bitmap[i + 3] = fg_color if byte & _BIT4 else bg_color + # bitmap[i + 4] = fg_color if byte & _BIT3 else bg_color + # bitmap[i + 5] = fg_color if byte & _BIT2 else bg_color + # bitmap[i + 6] = fg_color if byte & _BIT1 else bg_color + # bitmap[i + 7] = fg_color if byte & _BIT0 else bg_color + # idx += 1 + + # return buffer + + # @micropython.viper + # @staticmethod + # def _pack16(glyphs, idx: uint, fg_color: uint, bg_color: uint): + # """ + # Pack a character into a byte array. + + # Args: + # char (str): character to pack + + # Returns: + # 128 bytes: character bitmap in color565 format + # """ + + # buffer = bytearray(256) + # bitmap = ptr16(buffer) + # glyph = ptr8(glyphs) + + # for i in range(0, 128, 16): + # byte = glyph[idx] + + # bitmap[i] = fg_color if byte & _BIT7 else bg_color + # bitmap[i + 1] = fg_color if byte & _BIT6 else bg_color + # bitmap[i + 2] = fg_color if byte & _BIT5 else bg_color + # bitmap[i + 3] = fg_color if byte & _BIT4 else bg_color + # bitmap[i + 4] = fg_color if byte & _BIT3 else bg_color + # bitmap[i + 5] = fg_color if byte & _BIT2 else bg_color + # bitmap[i + 6] = fg_color if byte & _BIT1 else bg_color + # bitmap[i + 7] = fg_color if byte & _BIT0 else bg_color + # idx += 1 + + # byte = glyph[idx] + # bitmap[i + 8] = fg_color if byte & _BIT7 else bg_color + # bitmap[i + 9] = fg_color if byte & _BIT6 else bg_color + # bitmap[i + 10] = fg_color if byte & _BIT5 else bg_color + # bitmap[i + 11] = fg_color if byte & _BIT4 else bg_color + # bitmap[i + 12] = fg_color if byte & _BIT3 else bg_color + # bitmap[i + 13] = fg_color if byte & _BIT2 else bg_color + # bitmap[i + 14] = fg_color if byte & _BIT1 else bg_color + # bitmap[i + 15] = fg_color if byte & _BIT0 else bg_color + # idx += 1 + + # return buffer + + def _text8(self, font, text, x0, y0, fg_color=WHITE, bg_color=BLACK): + """ + Internal method to write characters with width of 8 and + heights of 8 or 16. + + Args: + font (module): font module to use + text (str): text to write + x0 (int): column to start drawing at + y0 (int): row to start drawing at + color (int): 565 encoded color to use for characters + background (int): 565 encoded color to use for background + """ + + for char in text: + ch = ord(char) + if ( + font.FIRST <= ch < font.LAST + and x0 + font.WIDTH <= self.width + and y0 + font.HEIGHT <= self.height + ): + if font.HEIGHT == 8: + passes = 1 + size = 8 + each = 0 + else: + passes = 2 + size = 16 + each = 8 + + for line in range(passes): + idx = (ch - font.FIRST) * size + (each * line) + buffer = self._pack8(font.FONT, idx, fg_color, bg_color) + self.blit_buffer(buffer, x0, y0 + 8 * line, 8, 8) + + x0 += 8 + + # def _text16(self, font, text, x0, y0, fg_color=WHITE, bg_color=BLACK): + # """ + # Internal method to draw characters with width of 16 and heights of 16 + # or 32. + + # Args: + # font (module): font module to use + # text (str): text to write + # x0 (int): column to start drawing at + # y0 (int): row to start drawing at + # color (int): 565 encoded color to use for characters + # background (int): 565 encoded color to use for background + # """ + + # for char in text: + # ch = ord(char) + # if ( + # font.FIRST <= ch < font.LAST + # and x0 + font.WIDTH <= self.width + # and y0 + font.HEIGHT <= self.height + # ): + # each = 16 + # if font.HEIGHT == 16: + # passes = 2 + # size = 32 + # else: + # passes = 4 + # size = 64 + + # for line in range(passes): + # idx = (ch - font.FIRST) * size + (each * line) + # buffer = self._pack16(font.FONT, idx, fg_color, bg_color) + # self.blit_buffer(buffer, x0, y0 + 8 * line, 16, 8) + # x0 += 16 + + def text(self, font, text, x0, y0, color=WHITE, background=BLACK): + """ + Draw text on display in specified font and colors. 8 and 16 bit wide + fonts are supported. + + Args: + font (module): font module to use. + text (str): text to write + x0 (int): column to start drawing at + y0 (int): row to start drawing at + color (int): 565 encoded color to use for characters + background (int): 565 encoded color to use for background + """ + fg_color = color if self.needs_swap else ((color << 8) & 0xFF00) | (color >> 8) + bg_color = ( + background + if self.needs_swap + else ((background << 8) & 0xFF00) | (background >> 8) + ) + + if font.WIDTH == 8: + self._text8(font, text, x0, y0, fg_color, bg_color) + else: + self._text16(font, text, x0, y0, fg_color, bg_color) + + def bitmap(self, bitmap, x, y, index=0): + """ + Draw a bitmap on display at the specified column and row + + Args: + bitmap (bitmap_module): The module containing the bitmap to draw + x (int): column to start drawing at + y (int): row to start drawing at + index (int): Optional index of bitmap to draw from multiple bitmap + module + """ + width = bitmap.WIDTH + height = bitmap.HEIGHT + to_col = x + width - 1 + to_row = y + height - 1 + if self.width <= to_col or self.height <= to_row: + return + + bitmap_size = height * width + buffer_len = bitmap_size * 2 + bpp = bitmap.BPP + bs_bit = bpp * bitmap_size * index # if index > 0 else 0 + palette = bitmap.PALETTE + needs_swap = self.needs_swap + buffer = bytearray(buffer_len) + + for i in range(0, buffer_len, 2): + color_index = 0 + for _ in range(bpp): + color_index = (color_index << 1) | ( + (bitmap.BITMAP[bs_bit >> 3] >> (7 - (bs_bit & 7))) & 1 + ) + bs_bit += 1 + + color = palette[color_index] + if needs_swap: + buffer[i] = color & 0xFF + buffer[i + 1] = color >> 8 + else: + buffer[i] = color >> 8 + buffer[i + 1] = color & 0xFF + + self._set_window(x, y, to_col, to_row) + self._write(None, buffer) + + def pbitmap(self, bitmap, x, y, index=0): + """ + Draw a bitmap on display at the specified column and row one row at a time + + Args: + bitmap (bitmap_module): The module containing the bitmap to draw + x (int): column to start drawing at + y (int): row to start drawing at + index (int): Optional index of bitmap to draw from multiple bitmap + module + + """ + width = bitmap.WIDTH + height = bitmap.HEIGHT + bitmap_size = height * width + bpp = bitmap.BPP + bs_bit = bpp * bitmap_size * index # if index > 0 else 0 + palette = bitmap.PALETTE + needs_swap = self.needs_swap + buffer = bytearray(bitmap.WIDTH * 2) + + for row in range(height): + for col in range(width): + color_index = 0 + for _ in range(bpp): + color_index <<= 1 + color_index |= ( + bitmap.BITMAP[bs_bit // 8] & 1 << (7 - (bs_bit % 8)) + ) > 0 + bs_bit += 1 + color = palette[color_index] + if needs_swap: + buffer[col * 2] = color & 0xFF + buffer[col * 2 + 1] = color >> 8 & 0xFF + else: + buffer[col * 2] = color >> 8 & 0xFF + buffer[col * 2 + 1] = color & 0xFF + + to_col = x + width - 1 + to_row = y + row + if self.width > to_col and self.height > to_row: + self._set_window(x, y + row, to_col, to_row) + self._write(None, buffer) + + def write(self, font, string, x, y, fg=WHITE, bg=BLACK): + """ + Write a string using a converted true-type font on the display starting + at the specified column and row + + Args: + font (font): The module containing the converted true-type font + s (string): The string to write + x (int): column to start writing + y (int): row to start writing + fg (int): foreground color, optional, defaults to WHITE + bg (int): background color, optional, defaults to BLACK + """ + buffer_len = font.HEIGHT * font.MAX_WIDTH * 2 + buffer = bytearray(buffer_len) + fg_hi = fg >> 8 + fg_lo = fg & 0xFF + + bg_hi = bg >> 8 + bg_lo = bg & 0xFF + + for character in string: + try: + char_index = font.MAP.index(character) + offset = char_index * font.OFFSET_WIDTH + bs_bit = font.OFFSETS[offset] + if font.OFFSET_WIDTH > 1: + bs_bit = (bs_bit << 8) + font.OFFSETS[offset + 1] + + if font.OFFSET_WIDTH > 2: + bs_bit = (bs_bit << 8) + font.OFFSETS[offset + 2] + + char_width = font.WIDTHS[char_index] + buffer_needed = char_width * font.HEIGHT * 2 + + for i in range(0, buffer_needed, 2): + if font.BITMAPS[bs_bit // 8] & 1 << (7 - (bs_bit % 8)) > 0: + buffer[i] = fg_hi + buffer[i + 1] = fg_lo + else: + buffer[i] = bg_hi + buffer[i + 1] = bg_lo + + bs_bit += 1 + + to_col = x + char_width - 1 + to_row = y + font.HEIGHT - 1 + if self.width > to_col and self.height > to_row: + self._set_window(x, y, to_col, to_row) + self._write(None, buffer[:buffer_needed]) + + x += char_width + + except ValueError: + pass + + def write_width(self, font, string): + """ + Returns the width in pixels of the string if it was written with the + specified font + + Args: + font (font): The module containing the converted true-type font + string (string): The string to measure + + Returns: + int: The width of the string in pixels + + """ + width = 0 + for character in string: + try: + char_index = font.MAP.index(character) + width += font.WIDTHS[char_index] + except ValueError: + pass + + return width + + @micropython.native + def polygon(self, points, x, y, color, angle=0, center_x=0, center_y=0): + """ + Draw a polygon on the display. + + Args: + points (list): List of points to draw. + x (int): X-coordinate of the polygon's position. + y (int): Y-coordinate of the polygon's position. + color (int): 565 encoded color. + angle (float): Rotation angle in radians (default: 0). + center_x (int): X-coordinate of the rotation center (default: 0). + center_y (int): Y-coordinate of the rotation center (default: 0). + + Raises: + ValueError: If the polygon has less than 3 points. + """ + if len(points) < 3: + raise ValueError("Polygon must have at least 3 points.") + + if angle: + cos_a = cos(angle) + sin_a = sin(angle) + rotated = [ + ( + x + + center_x + + int( + (point[0] - center_x) * cos_a - (point[1] - center_y) * sin_a + ), + y + + center_y + + int( + (point[0] - center_x) * sin_a + (point[1] - center_y) * cos_a + ), + ) + for point in points + ] + else: + rotated = [(x + int((point[0])), y + int((point[1]))) for point in points] + + for i in range(1, len(rotated)): + self.line( + rotated[i - 1][0], + rotated[i - 1][1], + rotated[i][0], + rotated[i][1], + color, + ) \ No newline at end of file diff --git a/src/seedsigner/hardware/microsd.py b/src/seedsigner/hardware/microsd.py index da545b7f8..38677d86d 100644 --- a/src/seedsigner/hardware/microsd.py +++ b/src/seedsigner/hardware/microsd.py @@ -4,7 +4,6 @@ from seedsigner.models.singleton import Singleton from seedsigner.models.threads import BaseThread -from seedsigner.models.settings import Settings logger = logging.getLogger(__name__) @@ -33,6 +32,8 @@ def get_instance(cls): @property def is_inserted(self): + from seedsigner.models.settings import Settings # Import here to avoid circular import issues + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: return os.path.exists(MicroSD.MOUNT_POINT) else: @@ -47,6 +48,7 @@ def start_detection(self): def run(self): from seedsigner.controller import Controller from seedsigner.gui.toast import SDCardStateChangeToastManagerThread + from seedsigner.models.settings import Settings # Import here to avoid circular import issues action = "" # explicitly only microsd add/remove detection in seedsigner-os diff --git a/src/seedsigner/models/settings.py b/src/seedsigner/models/settings.py index ae5b4ffe7..3310a58cb 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -108,7 +108,8 @@ def __str__(self): def save(self): - if self._data[SettingsConstants.SETTING__PERSISTENT_SETTINGS] == SettingsConstants.OPTION__ENABLED: + from seedsigner.hardware.microsd import MicroSD + if self._data[SettingsConstants.SETTING__PERSISTENT_SETTINGS] == SettingsConstants.OPTION__ENABLED and MicroSD.get_instance().is_inserted: with open(Settings.SETTINGS_FILENAME, 'w') as settings_file: json.dump(self._data, settings_file, indent=4) # SeedSignerOS makes removing the microsd possible, flush and then fsync forces persistent settings to disk @@ -146,6 +147,8 @@ def update(self, new_settings: dict): for key, value in new_settings.items(): self._data.pop(key, None) self._data[key] = value + + self.save() def set_value(self, attr_name: str, value: any): @@ -173,13 +176,16 @@ def set_value(self, attr_name: str, value: any): self.save() - def get_value(self, attr_name: str): + def get_value(self, attr_name: str, default_if_none: bool = None): """ Returns the attr's current value. Note that for multiselect, the current value is a List. """ if attr_name not in self._data: + if default_if_none: + return SettingsDefinition.get_settings_entry(attr_name).default_value + raise Exception(f"Setting for {attr_name} not found") return self._data[attr_name] diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 089a94b21..16502b40e 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -151,6 +151,9 @@ def map_network_to_embit(cls, network) -> str: SETTING__COORDINATORS = "coordinators" SETTING__BTC_DENOMINATION = "denomination" + SETTING__DISPLAY_CONFIGURATION = "display_config" + SETTING__DISPLAY_COLOR_INVERTED = "color_inverted" + SETTING__NETWORK = "network" SETTING__QR_DENSITY = "qr_density" SETTING__XPUB_EXPORT = "xpub_export" @@ -170,6 +173,20 @@ def map_network_to_embit(cls, network) -> str: SETTING__DEBUG = "debug" + + # Hardware config settings + DISPLAY_CONFIGURATION__ST7789__240x240 = "st7789_240x240" # default; original Waveshare 1.3" display hat + DISPLAY_CONFIGURATION__ST7789__240x320 = "st7789_240x320" + DISPLAY_CONFIGURATION__ILI9341__240x320 = "ili9341_240x320" # natively portrait dimensions; we apply a 90° rotation + DISPLAY_CONFIGURATION__ILI9486__320x480 = "ili9486_320x480" # natively portrait dimensions; we apply a 90° rotation + ALL_DISPLAY_CONFIGURATIONS = [ + (DISPLAY_CONFIGURATION__ST7789__240x240, "st7789 240x240"), + (DISPLAY_CONFIGURATION__ST7789__240x320, "st7789 240x320"), + (DISPLAY_CONFIGURATION__ILI9341__240x320, "ili9341 240x320"), + # (DISPLAY_CONFIGURATION__ILI9486__320x480, "ili9486 320x480"), # TODO: Enable when ili9486 driver performance is improved + ] + + # Hidden settings SETTING__QR_BRIGHTNESS = "qr_background_color" @@ -183,6 +200,7 @@ def map_network_to_embit(cls, network) -> str: VISIBILITY__GENERAL = "general" VISIBILITY__ADVANCED = "advanced" + VISIBILITY__HARDWARE = "hardware" VISIBILITY__DEVELOPER = "developer" VISIBILITY__HIDDEN = "hidden" # For data-only (e.g. custom_derivation), not configurable by the user @@ -512,6 +530,26 @@ class SettingsDefinition: visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__ENABLED), + + # Hardware config + SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM, + attr_name=SettingsConstants.SETTING__DISPLAY_CONFIGURATION, + abbreviated_name="disp_conf", + display_name="Display type", + type=SettingsConstants.TYPE__SELECT_1, + visibility=SettingsConstants.VISIBILITY__HARDWARE, + selection_options=SettingsConstants.ALL_DISPLAY_CONFIGURATIONS, + default_value=SettingsConstants.DISPLAY_CONFIGURATION__ST7789__240x240), + + SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM, + attr_name=SettingsConstants.SETTING__DISPLAY_COLOR_INVERTED, + abbreviated_name="rgb_inv", + display_name="Invert colors", + type=SettingsConstants.TYPE__ENABLED_DISABLED, + visibility=SettingsConstants.VISIBILITY__HARDWARE, + default_value=SettingsConstants.OPTION__DISABLED), + + # Developer options # TODO: No real Developer options needed yet. Disable for now. # SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM, diff --git a/src/seedsigner/views/screensaver.py b/src/seedsigner/views/screensaver.py index 84476da47..01c2cbfb4 100644 --- a/src/seedsigner/views/screensaver.py +++ b/src/seedsigner/views/screensaver.py @@ -41,26 +41,29 @@ def start(self): show_partner_logos = Settings.get_instance().get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED + logo_x = int((self.renderer.canvas_width - self.logo.width) / 2) + logo_y = int((self.renderer.canvas_height - self.logo.height) / 2) if show_partner_logos: - logo_offset_y = -56 - else: - logo_offset_y = 0 + logo_y -= 56 + + start = time.time() # Fade in alpha for i in range(250, -1, -25): self.logo.putalpha(255 - i) background = Image.new("RGBA", size=self.logo.size, color="black") - self.renderer.canvas.paste(Image.alpha_composite(background, self.logo), (0, logo_offset_y)) + self.renderer.canvas.paste(Image.alpha_composite(background, self.logo), (logo_x, logo_y)) self.renderer.show_image() + + print(f"{(time.time() - start)} elapsed") # Display version num below SeedSigner logo font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.TOP_NAV_TITLE_FONT_SIZE) version = f"v{controller.VERSION}" - (left, top, version_tw, version_th) = font.getbbox(version, anchor="lt") # The logo png is 240x240, but the actual logo is 70px tall, vertically centered version_x = int(self.renderer.canvas_width/2) - version_y = int(self.canvas_height/2) + 35 + logo_offset_y + GUIConstants.COMPONENT_PADDING + version_y = int(self.canvas_height/2) + 35 + logo_y + GUIConstants.COMPONENT_PADDING self.renderer.draw.text(xy=(version_x, version_y), text=version, font=font, fill=GUIConstants.ACCENT_COLOR, anchor="mt") self.renderer.show_image() @@ -97,17 +100,24 @@ def __init__(self, buttons): self.buttons = buttons - # Paste the logo in a bigger image that is 2x the size of the logo - self.image = Image.new("RGB", (2 * self.logo.size[0], 2 * self.logo.size[1]), (0,0,0)) - self.image.paste(self.logo, (int(self.logo.size[0] / 2), int(self.logo.size[1] / 2))) + # Paste the logo in a bigger image that is the canvas + the logo dims (half the + # logo will render off the canvas at each edge). + self.image = Image.new("RGB", (self.renderer.canvas_width + self.logo.width, self.renderer.canvas_height + self.logo.height), (0,0,0)) + + # Place the logo centered on the larger image + logo_x = int((self.image.width - self.logo.width) / 2) + logo_y = int((self.image.height - self.logo.height) / 2) + self.image.paste(self.logo, (logo_x, logo_y)) self.min_coords = (0, 0) - self.max_coords = (self.logo.size[0], self.logo.size[1]) + self.max_coords = (self.renderer.canvas_width, self.renderer.canvas_height) + + # Update our first rendering position so we're centered + self.cur_x = int(self.logo.width / 2) + self.cur_y = int(self.logo.height / 2) self.increment_x = self.rand_increment() self.increment_y = self.rand_increment() - self.cur_x = int(self.logo.size[0] / 2) - self.cur_y = int(self.logo.size[1] / 2) self._is_running = False self.last_screen = None @@ -150,7 +160,7 @@ def start(self): crop = self.image.crop(( self.cur_x, self.cur_y, self.cur_x + self.renderer.canvas_width, self.cur_y + self.renderer.canvas_height)) - self.renderer.disp.ShowImage(crop, 0, 0) + self.renderer.disp.show_image(crop, 0, 0) self.cur_x += self.increment_x self.cur_y += self.increment_y diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index e2a742ca7..296023708 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -42,22 +42,22 @@ def run(self): # Set up the next nested level of menuing button_data.append(("Advanced", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT)) - next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) - + advanced_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) button_data.append(self.IO_TEST) button_data.append(self.DONATE) elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED: title = "Advanced" - # So far there are no real Developer options; disabling for now - # button_data.append(("Developer Options", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT)) - # next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER}) - next_destination = None - + # The hardware options nest below "Advanced" + button_data.append(("Hardware", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT)) + hardware_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__HARDWARE}) + + elif self.visibility == SettingsConstants.VISIBILITY__HARDWARE: + title = "Hardware" + elif self.visibility == SettingsConstants.VISIBILITY__DEVELOPER: title = "Dev Options" - next_destination = None selected_menu_num = self.run_screen( ButtonListScreen, @@ -79,8 +79,15 @@ def run(self): else: return Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) - elif selected_menu_num == len(settings_entries): - return next_destination + elif isinstance(button_data[selected_menu_num], tuple): + if button_data[selected_menu_num][0] == "Advanced": + return advanced_destination + + elif button_data[selected_menu_num][0] == "Hardware": + return hardware_destination + + else: + raise ValueError(f"Unknown tuple value in settings menu: {button_data[selected_menu_num]}") elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == self.IO_TEST: return Destination(IOTestView) @@ -179,6 +186,12 @@ def run(self): value=updated_value ) + if self.settings_entry.attr_name == SettingsConstants.SETTING__DISPLAY_CONFIGURATION: + self.renderer.initialize_display() + + elif self.settings_entry.attr_name == SettingsConstants.SETTING__DISPLAY_COLOR_INVERTED: + self.renderer.disp.invert(enabled=updated_value == SettingsConstants.OPTION__ENABLED) + if destination: return destination @@ -196,9 +209,16 @@ def __init__(self, data: str): # May raise an Exception which will bubble up to the Controller to display to the # user. self.config_name, settings_update_dict = Settings.parse_settingsqr(data) + + changes_display_driver = ( + SettingsConstants.SETTING__DISPLAY_CONFIGURATION in settings_update_dict and + self.settings.get_value(SettingsConstants.SETTING__DISPLAY_CONFIGURATION) != settings_update_dict[SettingsConstants.SETTING__DISPLAY_CONFIGURATION]) self.settings.update(settings_update_dict) + if changes_display_driver: + self.renderer.initialize_display() + if MicroSD.get_instance().is_inserted and self.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__ENABLED: self.status_message = "Persistent Settings enabled. Settings saved to SD card." else: diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index eabb1171c..311149e1a 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -602,6 +602,7 @@ def __init__(self, is_change: bool = False, start_index: int = 0, selected_butto def run(self): + from seedsigner.gui.screens.tools_screens import ToolsAddressExplorerAddressListScreen self.loading_screen = None addresses = [] @@ -652,29 +653,12 @@ def run(self): # Everything is set. Stop the loading screen self.loading_screen.stop() - for i, address in enumerate(addresses): - cur_index = i + self.start_index - - # Adjust the trailing addr display length based on available room - # (the index number will push it out on each order of magnitude) - if cur_index < 10: - end_digits = -6 - elif cur_index < 100: - end_digits = -5 - else: - end_digits = -4 - button_data.append(f"{cur_index}:{address[:8]}...{address[end_digits:]}") - - button_data.append(("Next {}".format(addrs_per_screen), None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT)) - selected_menu_num = self.run_screen( - ButtonListScreen, + ToolsAddressExplorerAddressListScreen, title="{} Addrs".format("Receive" if not self.is_change else "Change"), - button_data=button_data, - button_font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, - button_font_size=GUIConstants.BUTTON_FONT_SIZE + 4, - is_button_text_centered=False, - is_bottom_list=True, + start_index=self.start_index, + addresses=addresses, + next_button=("Next {}".format(addrs_per_screen), None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT), selected_button=self.selected_button_index, scroll_y_initial_offset=self.initial_scroll, ) diff --git a/tests/base.py b/tests/base.py index 100ac7413..59a373da1 100644 --- a/tests/base.py +++ b/tests/base.py @@ -11,6 +11,8 @@ sys.modules['seedsigner.views.screensaver'] = MagicMock() sys.modules['seedsigner.hardware.buttons'] = MagicMock() sys.modules['seedsigner.hardware.camera'] = MagicMock() +sys.modules['seedsigner.hardware.st7789_mpy'] = MagicMock() +sys.modules['seedsigner.hardware.ili9341'] = MagicMock() from seedsigner.controller import Controller, FlowBasedTestException, StopFlowBasedTest from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 4c0c092a8..8fe3daa64 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -4,19 +4,15 @@ import sys import time from unittest.mock import Mock, patch, MagicMock -from seedsigner.helpers import embit_utils from embit import compact from embit.psbt import PSBT, OutputScope from embit.script import Script -from seedsigner.helpers import embit_utils -from seedsigner.models.psbt_parser import OPCODES, PSBTParser - - # Prevent importing modules w/Raspi hardware dependencies. # These must precede any SeedSigner imports. -sys.modules['seedsigner.hardware.ST7789'] = MagicMock() +sys.modules['seedsigner.hardware.displays.st7789_mpy'] = MagicMock() +sys.modules['seedsigner.hardware.displays.ili9341'] = MagicMock() sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock() sys.modules['seedsigner.views.screensaver'] = MagicMock() sys.modules['RPi'] = MagicMock() @@ -24,6 +20,9 @@ sys.modules['seedsigner.hardware.camera'] = MagicMock() sys.modules['seedsigner.hardware.microsd'] = MagicMock() +from seedsigner.gui.components import GUIConstants +from seedsigner.helpers import embit_utils +from seedsigner.models.psbt_parser import OPCODES, PSBTParser from seedsigner.controller import Controller from seedsigner.gui.renderer import Renderer @@ -144,6 +143,24 @@ def add_op_return_to_psbt(psbt: PSBT, raw_payload_data: bytes): "SettingsMenuView__Advanced" )) + # Render the nested "Hardware" submenu option at the end of "Advanced" + num_advanced_settings = len(SettingsDefinition.get_settings_entries(visibility=SettingsConstants.VISIBILITY__ADVANCED)) - 5 # hard-coded for 240px height: the first 5 settings options are already visible + settings_views_list.append(( + settings_views.SettingsMenuView, + dict( + visibility=SettingsConstants.VISIBILITY__ADVANCED, + selected_attr=SettingsConstants.SETTING__PARTNER_LOGOS, + initial_scroll=num_advanced_settings*GUIConstants.BUTTON_HEIGHT + (num_advanced_settings-1)*GUIConstants.COMPONENT_PADDING, # Force menu to scroll to the bottom + ), + "SettingsMenuView__Advanced_Hardware" + )) + + settings_views_list.append(( + settings_views.SettingsMenuView, + dict(visibility=SettingsConstants.VISIBILITY__HARDWARE), + "SettingsMenuView__Hardware" + )) + # so we get a choice for transcribe seed qr format controller.settings.set_value( attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR, @@ -349,6 +366,12 @@ def screencap_view(view_cls: View, view_args: dict = {}, view_name: str = None, # Re-render some screens that require more manual intervention / setup than the above # scripting can support. + screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, "seed_views")) + controller.storage.init_pending_mnemonic(num_words=12) + controller.storage.update_pending_mnemonic(word="sc", index=0) + screencap_view(seed_views.SeedMnemonicEntryView) + + screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, "psbt_views")) # Render the PSBTChangeDetailsView_multisig_unverified screenshot diff --git a/tests/screenshot_generator/utils.py b/tests/screenshot_generator/utils.py index 703bbf61a..bc6605d13 100644 --- a/tests/screenshot_generator/utils.py +++ b/tests/screenshot_generator/utils.py @@ -20,7 +20,7 @@ def configure_instance(cls): cls._instance = renderer # Hard-coding output values for now - renderer.canvas_width = 240 + renderer.canvas_width = 320 renderer.canvas_height = 240 renderer.canvas = Image.new('RGB', (renderer.canvas_width, renderer.canvas_height))