From b91d105591d13a8c23dd832d49b6405ad1a8cfe0 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Tue, 23 Jul 2024 10:38:18 -0500 Subject: [PATCH 01/26] rough prototype --- src/seedsigner/gui/renderer.py | 3 +- src/seedsigner/gui/screens/screen.py | 6 +- src/seedsigner/hardware/st7789_mpy.py | 1040 +++++++++++++++++++++++++ tests/screenshot_generator/utils.py | 2 +- 4 files changed, 1047 insertions(+), 4 deletions(-) create mode 100644 src/seedsigner/hardware/st7789_mpy.py diff --git a/src/seedsigner/gui/renderer.py b/src/seedsigner/gui/renderer.py index db0a4eb5..f4c52d68 100644 --- a/src/seedsigner/gui/renderer.py +++ b/src/seedsigner/gui/renderer.py @@ -23,7 +23,8 @@ class Renderer(ConfigurableSingleton): cls._instance = renderer # Eventually we'll be able to plug in other display controllers - renderer.disp = ST7789() + renderer.disp = ST7789(width=240, height=320) + renderer.canvas_width = renderer.disp.width renderer.canvas_height = renderer.disp.height diff --git a/src/seedsigner/gui/screens/screen.py b/src/seedsigner/gui/screens/screen.py index 03f7d180..120614ab 100644 --- a/src/seedsigner/gui/screens/screen.py +++ b/src/seedsigner/gui/screens/screen.py @@ -579,9 +579,11 @@ class LargeButtonScreen(BaseTopNavScreen): 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/hardware/st7789_mpy.py b/src/seedsigner/hardware/st7789_mpy.py new file mode 100644 index 00000000..609ebfae --- /dev/null +++ b/src/seedsigner/hardware/st7789_mpy.py @@ -0,0 +1,1040 @@ +""" +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 ShowImage(self,image,Xstart,Ystart): + """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.SetWindows ( 0, 0, self.width, self.height) + GPIO.output(self.dc,GPIO.HIGH) + # self.spi.writebytes2(pix) + self._write(data=pix) + + + def _write(self, command=None, data=None): + """SPI write to the device: commands and data.""" + if self.cs: + self.cs.off() + if command is not None: + GPIO.output(self.dc, GPIO.LOW) + # self.dc.off() + self.spi.writebytes2(command) + if data is not None: + GPIO.output(self.dc,GPIO.HIGH) + # self.dc.on() + self.spi.writebytes2(data) + if self.cs: + self.cs.on() + + def hard_reset(self): + """ + Hard reset display. + """ + if self.cs: + self.cs.off() + if self.reset: + GPIO.output(self.reset, GPIO.HIGH) + # self.reset.on() + sleep_ms(10) + if self.reset: + GPIO.output(self.reset, GPIO.LOW) + # self.reset.off() + sleep_ms(10) + if self.reset: + GPIO.output(self.reset, GPIO.HIGH) + # self.reset.on() + sleep_ms(120) + if self.cs: + GPIO.output(self.reset, GPIO.HIGH) + # self.cs.on() + + 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 SetWindows(self, x0, y0, x1, y1): + self._set_window(x0, y0, x1, y1) + + 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) + # self.dc.on() + 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/tests/screenshot_generator/utils.py b/tests/screenshot_generator/utils.py index d767bdf2..e818fee5 100644 --- a/tests/screenshot_generator/utils.py +++ b/tests/screenshot_generator/utils.py @@ -25,7 +25,7 @@ class ScreenshotRenderer(Renderer): 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)) From 6a4a03a7f815d14d282e489961dbfd758ef1541b Mon Sep 17 00:00:00 2001 From: kdmukai Date: Wed, 24 Jul 2024 09:40:18 -0500 Subject: [PATCH 02/26] Tweaks to SeedMnemonicEntryScreen layout --- src/seedsigner/gui/screens/seed_screens.py | 13 +++++++++++-- tests/base.py | 1 + tests/screenshot_generator/generator.py | 4 +++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index 7bf14c04..486d1399 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -31,8 +31,17 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen): 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 @@ -78,7 +87,7 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen): 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) diff --git a/tests/base.py b/tests/base.py index 74370dc7..ffd960f4 100644 --- a/tests/base.py +++ b/tests/base.py @@ -11,6 +11,7 @@ sys.modules['seedsigner.gui.toast'] = MagicMock() 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() from seedsigner.controller import Controller, FlowBasedTestException, StopFlowBasedTest from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON, ButtonOption diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index b81df21b..629a539b 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -14,7 +14,9 @@ from embit.script import Script # Prevent importing modules w/Raspi hardware dependencies. # These must precede any SeedSigner imports. sys.modules['seedsigner.hardware.ST7789'] = MagicMock() -sys.modules['seedsigner.views.screensaver.ScreensaverScreen'] = MagicMock() +sys.modules['seedsigner.hardware.st7789_mpy'] = MagicMock() +sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock() +sys.modules['seedsigner.views.screensaver'] = MagicMock() sys.modules['RPi'] = MagicMock() sys.modules['RPi.GPIO'] = MagicMock() sys.modules['seedsigner.hardware.camera'] = MagicMock() From f19d7e71de2e2a02c6e77fd8d19c72c4c1f0d4e5 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Wed, 24 Jul 2024 22:35:48 -0500 Subject: [PATCH 03/26] Expand xpub in SeedExportXpubDetailsScreen --- src/seedsigner/gui/screens/seed_screens.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index 486d1399..5bd86259 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -643,12 +643,17 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen): ) 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, - # TRANSLATOR_NOTE: Short for "BIP32 Extended Public Key" 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.get_body_font_size() + 2, screen_x=GUIConstants.COMPONENT_PADDING, From 84c0109be9efa1b3069803426766f6ef84ec9feb Mon Sep 17 00:00:00 2001 From: kdmukai Date: Wed, 24 Jul 2024 23:27:23 -0500 Subject: [PATCH 04/26] new `ToolsAddressExplorerAddressListScreen` --- src/seedsigner/gui/screens/tools_screens.py | 38 +++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py index 2ae2df35..a3df145b 100644 --- a/src/seedsigner/gui/screens/tools_screens.py +++ b/src/seedsigner/gui/screens/tools_screens.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from gettext import gettext as _ 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 @@ -462,3 +463,40 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen): 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__() From cfcfb4a5ce22da93f77b66203ca31b01affb9eaf Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 16 Aug 2024 14:16:58 -0500 Subject: [PATCH 05/26] Screensaver relative positioning Still seems a bit off (overshoots the horizontal center to the right), but close enough. --- src/seedsigner/views/screensaver.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/seedsigner/views/screensaver.py b/src/seedsigner/views/screensaver.py index d96f7f5d..38254b72 100644 --- a/src/seedsigner/views/screensaver.py +++ b/src/seedsigner/views/screensaver.py @@ -144,17 +144,24 @@ class ScreensaverScreen(LogoScreen): 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 From 87638e77e3cbd26aef99257e28bdd3c4b49d8365 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 16 Aug 2024 20:28:13 -0500 Subject: [PATCH 06/26] basic ili9341 working, but no portrait only --- src/seedsigner/gui/renderer.py | 12 +- src/seedsigner/hardware/ili9341.py | 344 +++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 src/seedsigner/hardware/ili9341.py diff --git a/src/seedsigner/gui/renderer.py b/src/seedsigner/gui/renderer.py index f4c52d68..5333b1d5 100644 --- a/src/seedsigner/gui/renderer.py +++ b/src/seedsigner/gui/renderer.py @@ -1,7 +1,8 @@ from PIL import Image, ImageDraw from threading import Lock -from seedsigner.hardware.ST7789 import ST7789 +# from seedsigner.hardware.st7789_mpy import ST7789 +from seedsigner.hardware.ili9341 import ILI9341, ILI9341_TFTWIDTH, ILI9341_TFTHEIGHT from seedsigner.models.singleton import ConfigurableSingleton @@ -23,10 +24,13 @@ class Renderer(ConfigurableSingleton): cls._instance = renderer # Eventually we'll be able to plug in other display controllers - renderer.disp = ST7789(width=240, height=320) + renderer.disp = ILI9341() + renderer.disp.begin() - renderer.canvas_width = renderer.disp.width - renderer.canvas_height = renderer.disp.height + renderer.canvas_width = ILI9341_TFTWIDTH + renderer.canvas_height = ILI9341_TFTHEIGHT + # renderer.canvas_width = renderer.disp.width + # renderer.canvas_height = renderer.disp.height renderer.canvas = Image.new('RGB', (renderer.canvas_width, renderer.canvas_height)) renderer.draw = ImageDraw.Draw(renderer.canvas) diff --git a/src/seedsigner/hardware/ili9341.py b/src/seedsigner/hardware/ili9341.py new file mode 100644 index 00000000..ea5e19af --- /dev/null +++ b/src/seedsigner/hardware/ili9341.py @@ -0,0 +1,344 @@ +# 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. +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; then per-pixel byteswap to 16-bit RGB-5:6:5 + 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): + """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 + # 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)) + + 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 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 display(self, image=None): + """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 + # Set address bounds to entire display. + self.set_window() + # 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(image) + # Write data to hardware. + self.data(pixelbytes) + + def ShowImage(self, image, x, y): + self.display(image) + + 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 From 1e6dd1babd87b594698383fc16af7fe1942158ef Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 19 Aug 2024 20:10:37 -0500 Subject: [PATCH 07/26] Support for new IPS display --- src/seedsigner/gui/renderer.py | 5 +++-- src/seedsigner/hardware/ili9341.py | 20 ++++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/seedsigner/gui/renderer.py b/src/seedsigner/gui/renderer.py index 5333b1d5..e78d8368 100644 --- a/src/seedsigner/gui/renderer.py +++ b/src/seedsigner/gui/renderer.py @@ -26,9 +26,10 @@ class Renderer(ConfigurableSingleton): # Eventually we'll be able to plug in other display controllers renderer.disp = ILI9341() renderer.disp.begin() + renderer.disp.invert() - renderer.canvas_width = ILI9341_TFTWIDTH - renderer.canvas_height = ILI9341_TFTHEIGHT + renderer.canvas_width = ILI9341_TFTHEIGHT + renderer.canvas_height = ILI9341_TFTWIDTH # renderer.canvas_width = renderer.disp.width # renderer.canvas_height = renderer.disp.height diff --git a/src/seedsigner/hardware/ili9341.py b/src/seedsigner/hardware/ili9341.py index ea5e19af..8eab9852 100644 --- a/src/seedsigner/hardware/ili9341.py +++ b/src/seedsigner/hardware/ili9341.py @@ -18,6 +18,9 @@ # 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) +""" import numbers import time # import numpy as np @@ -123,7 +126,7 @@ class ILI9341(object): """Representation of an ILI9341 TFT LCD.""" def __init__(self, dc=22, rst=13, led=12, width=ILI9341_TFTWIDTH, - height=ILI9341_TFTHEIGHT): + 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 @@ -138,6 +141,8 @@ class ILI9341(object): 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. @@ -289,6 +294,17 @@ class ILI9341(object): 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 @@ -327,7 +343,7 @@ class ILI9341(object): # 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(image) + pixelbytes = image_to_data(image.rotate(self.rotation, expand=True)) # Write data to hardware. self.data(pixelbytes) From e33fac8f2cfbad6c58f08a5c9214f4331a6d2f9e Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 25 Aug 2024 09:35:14 -0500 Subject: [PATCH 08/26] Starting to generalize support for the different displays --- src/seedsigner/gui/renderer.py | 46 +++-- src/seedsigner/hardware/ST7789.py | 169 ------------------ src/seedsigner/hardware/displays/__init__.py | 0 .../hardware/displays/display_driver.py | 46 +++++ .../hardware/{ => displays}/ili9341.py | 32 +++- .../hardware/{ => displays}/st7789_mpy.py | 31 ++-- src/seedsigner/models/settings.py | 5 +- src/seedsigner/models/settings_definition.py | 36 ++++ src/seedsigner/views/screensaver.py | 2 +- src/seedsigner/views/settings_views.py | 25 ++- tests/base.py | 1 + tests/screenshot_generator/generator.py | 24 ++- 12 files changed, 196 insertions(+), 221 deletions(-) delete mode 100644 src/seedsigner/hardware/ST7789.py create mode 100644 src/seedsigner/hardware/displays/__init__.py create mode 100644 src/seedsigner/hardware/displays/display_driver.py rename src/seedsigner/hardware/{ => displays}/ili9341.py (92%) rename src/seedsigner/hardware/{ => displays}/st7789_mpy.py (98%) diff --git a/src/seedsigner/gui/renderer.py b/src/seedsigner/gui/renderer.py index e78d8368..1a854d6a 100644 --- a/src/seedsigner/gui/renderer.py +++ b/src/seedsigner/gui/renderer.py @@ -2,7 +2,10 @@ from PIL import Image, ImageDraw from threading import Lock # from seedsigner.hardware.st7789_mpy import ST7789 -from seedsigner.hardware.ili9341 import ILI9341, ILI9341_TFTWIDTH, ILI9341_TFTHEIGHT +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,24 +26,39 @@ class Renderer(ConfigurableSingleton): renderer = cls.__new__(cls) cls._instance = renderer - # Eventually we'll be able to plug in other display controllers - renderer.disp = ILI9341() - renderer.disp.begin() - renderer.disp.invert() + renderer.initialize_display() - renderer.canvas_width = ILI9341_TFTHEIGHT - renderer.canvas_height = ILI9341_TFTWIDTH - # renderer.canvas_width = renderer.disp.width - # renderer.canvas_height = renderer.disp.height - renderer.canvas = Image.new('RGB', (renderer.canvas_width, renderer.canvas_height)) - renderer.draw = ImageDraw.Draw(renderer.canvas) + def initialize_display(self): + # TODO: How to handle unspecified settings + non-default display hardware? + 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) 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: @@ -52,7 +70,7 @@ class Renderer(ConfigurableSingleton): # 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): @@ -86,7 +104,7 @@ class Renderer(ConfigurableSingleton): # 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/hardware/ST7789.py b/src/seedsigner/hardware/ST7789.py deleted file mode 100644 index ebd4952b..00000000 --- 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 00000000..e69de29b diff --git a/src/seedsigner/hardware/displays/display_driver.py b/src/seedsigner/hardware/displays/display_driver.py new file mode 100644 index 00000000..8aee00be --- /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 != 240 or width not in [240, 320]: + 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/ili9341.py b/src/seedsigner/hardware/displays/ili9341.py similarity index 92% rename from src/seedsigner/hardware/ili9341.py rename to src/seedsigner/hardware/displays/ili9341.py index 8eab9852..1330aa7c 100644 --- a/src/seedsigner/hardware/ili9341.py +++ b/src/seedsigner/hardware/displays/ili9341.py @@ -20,6 +20,9 @@ # 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 @@ -116,7 +119,11 @@ def image_to_data(image): # 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; then per-pixel byteswap to 16-bit RGB-5:6:5 + # 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() @@ -160,6 +167,15 @@ class ILI9341(object): # 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 @@ -328,7 +344,7 @@ class ILI9341(object): self.data(y1) # YEND self.command(ILI9341_RAMWR) # write to RAM - def display(self, image=None): + 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 @@ -337,19 +353,19 @@ class ILI9341(object): # By default write the internal buffer to the display. if image is None: image = self.buffer - # Set address bounds to entire display. - self.set_window() + + 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(image.rotate(self.rotation, expand=True)) + pixelbytes = image_to_data(output_image) + # Write data to hardware. self.data(pixelbytes) - def ShowImage(self, image, x, y): - self.display(image) - def clear(self, color=(0,0,0)): """Clear the image buffer to the specified RGB color (default black).""" width, height = self.buffer.size diff --git a/src/seedsigner/hardware/st7789_mpy.py b/src/seedsigner/hardware/displays/st7789_mpy.py similarity index 98% rename from src/seedsigner/hardware/st7789_mpy.py rename to src/seedsigner/hardware/displays/st7789_mpy.py index 609ebfae..b31593e3 100644 --- a/src/seedsigner/hardware/st7789_mpy.py +++ b/src/seedsigner/hardware/displays/st7789_mpy.py @@ -341,7 +341,10 @@ class ST7789: self._write(command, data) sleep_ms(delay) - def ShowImage(self,image,Xstart,Ystart): + def invert(self, enabled: bool = True): + raise Exception("Invert not implemented") + + 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""" @@ -351,52 +354,46 @@ class ST7789: 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) - self._write(data=pix) + 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: - self.cs.off() + GPIO.output(self.cs, GPIO.LOW) if command is not None: GPIO.output(self.dc, GPIO.LOW) - # self.dc.off() self.spi.writebytes2(command) if data is not None: GPIO.output(self.dc,GPIO.HIGH) - # self.dc.on() self.spi.writebytes2(data) if self.cs: - self.cs.on() + GPIO.output(self.cs,GPIO.HIGH) def hard_reset(self): """ Hard reset display. """ if self.cs: - self.cs.off() + GPIO.output(self.cs, GPIO.LOW) if self.reset: GPIO.output(self.reset, GPIO.HIGH) - # self.reset.on() sleep_ms(10) if self.reset: GPIO.output(self.reset, GPIO.LOW) - # self.reset.off() sleep_ms(10) if self.reset: GPIO.output(self.reset, GPIO.HIGH) - # self.reset.on() sleep_ms(120) if self.cs: - GPIO.output(self.reset, GPIO.HIGH) - # self.cs.on() + GPIO.output(self.cs, GPIO.HIGH) def soft_reset(self): """ @@ -462,9 +459,6 @@ class ST7789: self._write(_ST7789_MADCTL, bytes([madctl])) - def SetWindows(self, x0, y0, x1, y1): - self._set_window(x0, y0, x1, y1) - def _set_window(self, x0, y0, x1, y1): """ Set window to column and row address. @@ -574,7 +568,6 @@ class ST7789: _ENCODE_PIXEL_SWAPPED if self.needs_swap else _ENCODE_PIXEL, color ) GPIO.output(self.dc,GPIO.HIGH) - # self.dc.on() if chunks: data = pixel * _BUFFER_SIZE for _ in range(chunks): diff --git a/src/seedsigner/models/settings.py b/src/seedsigner/models/settings.py index 3c26d797..112fb41c 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -191,13 +191,16 @@ class Settings(Singleton): self.load_locale() - 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 f554b930..82f1957c 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -320,6 +320,9 @@ class SettingsConstants: 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" @@ -339,6 +342,20 @@ class SettingsConstants: SETTING__DEBUG = "debug" + + # Hardware config settings + DISPLAY_CONFIGURATION__ST7789__240x240 = "st7789_240x240" # default; original Waveshare 1.3" display hat + DISPLAY_CONFIGURATION__ST7789__320x240 = "st7789_320x240" + DISPLAY_CONFIGURATION__ILI9341__240x320 = "ili9341_240x320" # natively portrait dimensions; we apply a 90° rotation + DISPLAY_CONFIGURATION__ILI9486__480x320 = "ili9486_320x480" # natively portrait dimensions; we apply a 90° rotation + ALL_DISPLAY_CONFIGURATIONS = [ + (DISPLAY_CONFIGURATION__ST7789__240x240, "st7789 240x240"), + (DISPLAY_CONFIGURATION__ST7789__320x240, "st7789 320x240"), + (DISPLAY_CONFIGURATION__ILI9341__240x320, "ili9341 240x320"), + # (DISPLAY_CONFIGURATION__ILI9486__480x320, "ili9486 320x480"), # TODO: Enable when ili9486 driver performance is improved + ] + + # Hidden settings SETTING__QR_BRIGHTNESS = "qr_background_color" @@ -351,6 +368,7 @@ class SettingsConstants: CATEGORY__FEATURES = "features" VISIBILITY__GENERAL = "general" + VISIBILITY__HARDWARE = "hardware" VISIBILITY__ADVANCED = "advanced" VISIBILITY__DEVELOPER = "developer" VISIBILITY__HIDDEN = "hidden" # For data-only (e.g. custom_derivation), not configurable by the user @@ -681,6 +699,24 @@ class SettingsDefinition: visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__ENABLED), + + # Hardware config + SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM, + attr_name=SettingsConstants.SETTING__DISPLAY_CONFIGURATION, + 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, + 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 38254b72..03e66686 100644 --- a/src/seedsigner/views/screensaver.py +++ b/src/seedsigner/views/screensaver.py @@ -204,7 +204,7 @@ class ScreensaverScreen(LogoScreen): 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 17db700e..6a4f6955 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -13,6 +13,8 @@ logger = logging.getLogger(__name__) class SettingsMenuView(View): + ADVANCED = ButtonOption("Advanced", right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT) + HARDWARE = ButtonOption("Hardware", right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT) IO_TEST = ButtonOption("I/O test") DONATE = ButtonOption("Donate") @@ -29,7 +31,7 @@ class SettingsMenuView(View): settings_entries = SettingsDefinition.get_settings_entries( visibility=self.visibility ) - button_data=[ButtonOption(e.display_name) for e in settings_entries] + button_data: list[ButtonOption] = [ButtonOption(e.display_name) for e in settings_entries] selected_button = 0 if self.selected_attr: @@ -42,7 +44,7 @@ class SettingsMenuView(View): title = _("Settings") # Set up the next nested level of menuing - button_data.append(ButtonOption("Advanced", right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT)) + button_data.append(self.ADVANCED) next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) button_data.append(self.IO_TEST) @@ -51,11 +53,14 @@ class SettingsMenuView(View): 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}) + # The hardware options nest below "Advanced" + button_data.append(self.HARDWARE) + next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__HARDWARE}) + + elif self.visibility == SettingsConstants.VISIBILITY__HARDWARE: + title = "Hardware" next_destination = None - + elif self.visibility == SettingsConstants.VISIBILITY__DEVELOPER: title = _("Dev Options") next_destination = None @@ -80,7 +85,10 @@ class SettingsMenuView(View): else: return Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) - elif selected_menu_num == len(settings_entries): + if button_data[selected_menu_num] == self.ADVANCED: + return next_destination + + elif button_data[selected_menu_num] == self.HARDWARE: return next_destination elif button_data[selected_menu_num] == self.IO_TEST: @@ -222,6 +230,9 @@ class SettingsEntryUpdateSelectionView(View): value=updated_value ) + if self.settings_entry.attr_name == SettingsConstants.SETTING__DISPLAY_COLOR_INVERTED: + self.renderer.disp.invert(enabled=updated_value == SettingsConstants.OPTION__ENABLED) + if destination: return destination diff --git a/tests/base.py b/tests/base.py index ffd960f4..e89174ca 100644 --- a/tests/base.py +++ b/tests/base.py @@ -12,6 +12,7 @@ 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, ButtonOption diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 629a539b..c3142b25 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -11,10 +11,12 @@ from embit import compact from embit.psbt import PSBT, OutputScope from embit.script import Script +from seedsigner.gui.components import GUIConstants + # Prevent importing modules w/Raspi hardware dependencies. # These must precede any SeedSigner imports. -sys.modules['seedsigner.hardware.ST7789'] = MagicMock() -sys.modules['seedsigner.hardware.st7789_mpy'] = 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() @@ -176,6 +178,24 @@ def generate_screenshots(locale): ) ) + # 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, From 75542ece6469a3e78e877689cc0fb56034396d67 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 25 Aug 2024 09:46:48 -0500 Subject: [PATCH 09/26] Update settings_definition.py --- src/seedsigner/models/settings_definition.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 82f1957c..6ea7bda9 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -703,6 +703,7 @@ class SettingsDefinition: # 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, @@ -711,6 +712,7 @@ class SettingsDefinition: 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, From f843c6241d3127d1b7c71f6afdcd956e33c9dff2 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 25 Aug 2024 10:06:00 -0500 Subject: [PATCH 10/26] on-the-fly display driver changes via SettingsQR --- src/seedsigner/gui/renderer.py | 7 ++++++- src/seedsigner/hardware/microsd.py | 4 +++- src/seedsigner/models/settings.py | 5 +++-- src/seedsigner/views/settings_views.py | 7 +++++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/seedsigner/gui/renderer.py b/src/seedsigner/gui/renderer.py index 1a854d6a..9ec58daf 100644 --- a/src/seedsigner/gui/renderer.py +++ b/src/seedsigner/gui/renderer.py @@ -30,7 +30,10 @@ class Renderer(ConfigurableSingleton): def initialize_display(self): - # TODO: How to handle unspecified settings + non-default display hardware? + # 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: @@ -54,6 +57,8 @@ class Renderer(ConfigurableSingleton): 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: diff --git a/src/seedsigner/hardware/microsd.py b/src/seedsigner/hardware/microsd.py index da545b7f..38677d86 100644 --- a/src/seedsigner/hardware/microsd.py +++ b/src/seedsigner/hardware/microsd.py @@ -4,7 +4,6 @@ import time 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 @@ class MicroSD(Singleton, BaseThread): @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 @@ class MicroSD(Singleton, BaseThread): 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 112fb41c..19eba895 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -123,7 +123,8 @@ class Settings(Singleton): 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 @@ -200,7 +201,7 @@ class Settings(Singleton): 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/views/settings_views.py b/src/seedsigner/views/settings_views.py index 6a4f6955..216b00a5 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -251,9 +251,16 @@ class SettingsIngestSettingsQRView(View): # 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: From 14ca507253fea881780fcfe7271b270ce2853f73 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 25 Aug 2024 10:13:02 -0500 Subject: [PATCH 11/26] Update settings_definition.py --- src/seedsigner/models/settings_definition.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 6ea7bda9..1cf77e54 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -347,12 +347,12 @@ class SettingsConstants: DISPLAY_CONFIGURATION__ST7789__240x240 = "st7789_240x240" # default; original Waveshare 1.3" display hat DISPLAY_CONFIGURATION__ST7789__320x240 = "st7789_320x240" DISPLAY_CONFIGURATION__ILI9341__240x320 = "ili9341_240x320" # natively portrait dimensions; we apply a 90° rotation - DISPLAY_CONFIGURATION__ILI9486__480x320 = "ili9486_320x480" # 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__320x240, "st7789 320x240"), (DISPLAY_CONFIGURATION__ILI9341__240x320, "ili9341 240x320"), - # (DISPLAY_CONFIGURATION__ILI9486__480x320, "ili9486 320x480"), # TODO: Enable when ili9486 driver performance is improved + # (DISPLAY_CONFIGURATION__ILI9486__320x480, "ili9486 320x480"), # TODO: Enable when ili9486 driver performance is improved ] @@ -368,8 +368,8 @@ class SettingsConstants: CATEGORY__FEATURES = "features" VISIBILITY__GENERAL = "general" - VISIBILITY__HARDWARE = "hardware" VISIBILITY__ADVANCED = "advanced" + VISIBILITY__HARDWARE = "hardware" VISIBILITY__DEVELOPER = "developer" VISIBILITY__HIDDEN = "hidden" # For data-only (e.g. custom_derivation), not configurable by the user From 61f165577498b1e8a434505d2504c63c199cac78 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 25 Aug 2024 13:03:34 -0500 Subject: [PATCH 12/26] Update st7789_mpy.py --- src/seedsigner/hardware/displays/st7789_mpy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/hardware/displays/st7789_mpy.py b/src/seedsigner/hardware/displays/st7789_mpy.py index b31593e3..5a7bd5f8 100644 --- a/src/seedsigner/hardware/displays/st7789_mpy.py +++ b/src/seedsigner/hardware/displays/st7789_mpy.py @@ -342,7 +342,7 @@ class ST7789: sleep_ms(delay) def invert(self, enabled: bool = True): - raise Exception("Invert not implemented") + 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.""" From 6bfd7c4d3ff9fff407c369d7f6596ea5826aa028 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 25 Aug 2024 16:23:16 -0500 Subject: [PATCH 13/26] compatibility for st7789 240x320; re-init display on settings change --- src/seedsigner/hardware/displays/display_driver.py | 2 +- src/seedsigner/models/settings_definition.py | 4 ++-- src/seedsigner/views/settings_views.py | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/seedsigner/hardware/displays/display_driver.py b/src/seedsigner/hardware/displays/display_driver.py index 8aee00be..5421abfb 100644 --- a/src/seedsigner/hardware/displays/display_driver.py +++ b/src/seedsigner/hardware/displays/display_driver.py @@ -13,7 +13,7 @@ class DisplayDriver: if self.display_type == DISPLAY_TYPE__ST7789: from seedsigner.hardware.displays.st7789_mpy import ST7789 - if height != 240 or width not in [240, 320]: + 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) diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 1cf77e54..b2dbf1d3 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -345,12 +345,12 @@ class SettingsConstants: # Hardware config settings DISPLAY_CONFIGURATION__ST7789__240x240 = "st7789_240x240" # default; original Waveshare 1.3" display hat - DISPLAY_CONFIGURATION__ST7789__320x240 = "st7789_320x240" + 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__320x240, "st7789 320x240"), + (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 ] diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 216b00a5..b6fa7899 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -230,7 +230,10 @@ class SettingsEntryUpdateSelectionView(View): value=updated_value ) - if self.settings_entry.attr_name == SettingsConstants.SETTING__DISPLAY_COLOR_INVERTED: + 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: From 166c7c85196ca3ec65fe54130cd2885885253c89 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 18 Apr 2025 12:27:15 -0500 Subject: [PATCH 14/26] Bugfixes --- src/seedsigner/gui/screens/seed_screens.py | 4 +-- tests/screenshot_generator/generator.py | 38 ++++++++++++---------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index 5bd86259..a3f24cf2 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -35,7 +35,7 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen): # 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 + matches_list_highlight_font_size = GUIConstants.get_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 @@ -644,7 +644,7 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen): self.components.append(self.derivation_line) font_name = GUIConstants.FIXED_WIDTH_FONT_NAME - font_size = GUIConstants.BODY_FONT_SIZE + 2 + font_size = GUIConstants.get_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 diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index c3142b25..5234ae1d 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -11,14 +11,11 @@ from embit import compact from embit.psbt import PSBT, OutputScope from embit.script import Script -from seedsigner.gui.components import GUIConstants - # Prevent importing modules w/Raspi hardware dependencies. # These must precede any SeedSigner imports. 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['seedsigner.views.screensaver.ScreensaverScreen'] = MagicMock() sys.modules['RPi'] = MagicMock() sys.modules['RPi.GPIO'] = MagicMock() sys.modules['seedsigner.hardware.camera'] = MagicMock() @@ -28,6 +25,7 @@ sys.modules['seedsigner.hardware.microsd'] = MagicMock() patch('PIL.ImageFont.core.HAVE_RAQM', False).start() from seedsigner.controller import Controller +from seedsigner.gui.components import GUIConstants from seedsigner.gui.renderer import Renderer from seedsigner.gui.screens.seed_screens import SeedAddPassphraseScreen from seedsigner.gui.toast import RemoveSDCardToastManagerThread, SDCardStateChangeToastManagerThread @@ -180,21 +178,25 @@ def generate_screenshots(locale): # 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( + ScreenshotConfig( + 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 + ), + screenshot_name="SettingsMenuView__Advanced_Hardware" + ) + ) - settings_views_list.append(( - settings_views.SettingsMenuView, - dict(visibility=SettingsConstants.VISIBILITY__HARDWARE), - "SettingsMenuView__Hardware" - )) + settings_views_list.append( + ScreenshotConfig( + settings_views.SettingsMenuView, + dict(visibility=SettingsConstants.VISIBILITY__HARDWARE), + screenshot_name="SettingsMenuView__Hardware" + ) + ) # so we get a choice for transcribe seed qr format controller.settings.set_value( From 7a0bff793ea489e76748d506e862ecd13c298ecb Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 18 Apr 2025 13:33:17 -0500 Subject: [PATCH 15/26] Restore original ST7789 driver for 240x240 --- src/seedsigner/hardware/displays/ST7789.py | 169 ++++++++++++++++++ .../hardware/displays/display_driver.py | 17 +- 2 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 src/seedsigner/hardware/displays/ST7789.py diff --git a/src/seedsigner/hardware/displays/ST7789.py b/src/seedsigner/hardware/displays/ST7789.py new file mode 100644 index 00000000..ee272020 --- /dev/null +++ b/src/seedsigner/hardware/displays/ST7789.py @@ -0,0 +1,169 @@ +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 show_image(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) \ No newline at end of file diff --git a/src/seedsigner/hardware/displays/display_driver.py b/src/seedsigner/hardware/displays/display_driver.py index 5421abfb..0d5aee4e 100644 --- a/src/seedsigner/hardware/displays/display_driver.py +++ b/src/seedsigner/hardware/displays/display_driver.py @@ -12,10 +12,19 @@ class DisplayDriver: 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) + + if height == 240: + # TODO: For now the original ST7789 driver has to be used for 240x240. + # The mpy version below renders incorrectly (almost like each row of pixels + # is one pixel short, so the entire screen exhibits a diagonal skew). + from seedsigner.hardware.displays.ST7789 import ST7789 + self.display = ST7789() + + elif height == 320: + from seedsigner.hardware.displays.st7789_mpy import ST7789 + self.display = ST7789(width=width, height=height) elif self.display_type == DISPLAY_TYPE__ILI9341: from seedsigner.hardware.displays.ili9341 import ILI9341 @@ -25,6 +34,10 @@ class DisplayDriver: elif self.display_type == DISPLAY_TYPE__ILI9486: # TODO: improve performance of ili9486 driver raise Exception("ILI9486 display not implemented yet") + + + def __str__(self): + return f"DisplayDriver(display_type={self.display_type}, width={self.width}, height={self.height})" @property From 11d0969672cecf283e67d4dee171c57794429ba2 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sat, 19 Apr 2025 17:25:22 -0500 Subject: [PATCH 16/26] Fix centering on OpeningSplashScreen for 320x240 --- src/seedsigner/views/screensaver.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/views/screensaver.py b/src/seedsigner/views/screensaver.py index 03e66686..d607f6ef 100644 --- a/src/seedsigner/views/screensaver.py +++ b/src/seedsigner/views/screensaver.py @@ -77,6 +77,8 @@ class OpeningSplashScreen(LogoScreen): if self.force_partner_logos is not None: show_partner_logos = self.force_partner_logos + logo_offset_x = int((self.canvas_width - self.logo.width)/2) + if show_partner_logos: logo_offset_y = -56 else: @@ -87,11 +89,14 @@ class OpeningSplashScreen(LogoScreen): # Fade in alpha for i in range(250, -1, -25): self.logo.putalpha(255 - i) - 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_offset_x, logo_offset_y) + ) self.renderer.show_image() else: # Skip animation for the screenshot generator - self.renderer.canvas.paste(self.logo, (0, logo_offset_y)) + self.renderer.canvas.paste(self.logo, (logo_offset_x, logo_offset_y)) # Display version num below SeedSigner logo font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_top_nav_title_font_size()) From 639afc2e4ad869b7412d11fe2d657e19bd58a339 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sat, 19 Apr 2025 17:26:06 -0500 Subject: [PATCH 17/26] Aspect ratio-savvy camera frame resizing for non-square displays --- src/seedsigner/gui/components.py | 44 +++++++++++++++++++++ src/seedsigner/gui/screens/scan_screens.py | 9 ++--- src/seedsigner/gui/screens/tools_screens.py | 38 +++++++++++++++--- src/seedsigner/views/tools_views.py | 28 +++++++------ 4 files changed, 95 insertions(+), 24 deletions(-) diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index eee30773..7112f767 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -1986,3 +1986,47 @@ def reflow_text_into_pages(text: str, pages.append("\n".join(lines[i:i+lines_per_page])) return pages + + +def resize_image_to_fill(img: Image, target_size_x: int, target_size_y: int, sampling_method=Image.Resampling.NEAREST) -> Image: + """ + Resizes the image to fill the target size, cropping the image if necessary. + """ + if img.width == target_size_x and img.height == target_size_y: + # No need to resize + return img + + # if the image aspect ratio doesn't match the render area, we + # need to provide an aspect ratio-aware crop box. + render_aspect_ratio = target_size_x / target_size_y + source_frame_aspect_ratio = img.width / img.height + if render_aspect_ratio > source_frame_aspect_ratio: + # Render surface is wider than the source frame; preserve + # the width but crop the height + cropped_height = (img.width * target_size_y / target_size_x) + box = ( + 0, + int((img.height - cropped_height)/2), + img.width, + img.height - int((img.height - cropped_height)/2), + ) + + elif render_aspect_ratio < source_frame_aspect_ratio: + # Render surface is taller than the source frame; preserve + # the height but crop the width + box = ( + int((img.width - img.height * target_size_x / target_size_y) / 2), + 0, + int(img.width - (img.width - img.height * target_size_x / target_size_y) / 2), + img.height, + ) + + else: + # Render surface and source frame are the same aspect ratio + box = None + + return img.resize( + (target_size_x, target_size_y), + resample=sampling_method, + box=box, + ) \ No newline at end of file diff --git a/src/seedsigner/gui/screens/scan_screens.py b/src/seedsigner/gui/screens/scan_screens.py index c030fef4..7179f5af 100644 --- a/src/seedsigner/gui/screens/scan_screens.py +++ b/src/seedsigner/gui/screens/scan_screens.py @@ -5,7 +5,7 @@ from gettext import gettext as _ from PIL import Image, ImageDraw from seedsigner.gui import renderer -from seedsigner.gui.components import GUIConstants, Fonts +from seedsigner.gui.components import GUIConstants, Fonts, resize_image_to_fill from seedsigner.models.decode_qr import DecodeQR from seedsigner.models.threads import BaseThread, ThreadsafeCounter @@ -132,11 +132,8 @@ class ScanScreen(BaseScreen): scan_text += f" {cur_fps:0.2f} | {self.decoder_fps}" with self.renderer.lock: - if frame.width > self.render_width or frame.height > self.render_height: - frame = frame.resize( - (self.render_width, self.render_height), - resample=Image.NEAREST # Use nearest neighbor for max speed - ) + # Use nearest neighbor resizing for max speed + frame = resize_image_to_fill(frame, self.render_width, self.render_height, sampling_method=Image.Resampling.NEAREST) if scan_text: # Note: shadowed text (adding a 'stroke' outline) can diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py index a3df145b..7e8c777b 100644 --- a/src/seedsigner/gui/screens/tools_screens.py +++ b/src/seedsigner/gui/screens/tools_screens.py @@ -20,7 +20,12 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen): super().__post_init__() self.camera = Camera.get_instance() - self.camera.start_video_stream_mode(resolution=(self.canvas_width, self.canvas_height), framerate=24, format="rgb") + + # If the stream is set to 320x240, we get pillarboxed frames (black bars on the + # sides). But passing in square dims gives us an edge-to-edge image. + # TODO: Figure out why (camera expecting frame dims of multiples other than 16?) + max_dimension = max(self.canvas_width, self.canvas_height) + self.camera.start_video_stream_mode(resolution=(max_dimension, max_dimension), framerate=24, format="rgb") def _run(self): @@ -37,13 +42,38 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen): self.camera.stop_video_stream_mode() return RET_CODE__BACK_BUTTON - frame = self.camera.read_video_stream(as_image=True) + frame: Image = self.camera.read_video_stream(as_image=True) if frame is None: # Camera probably isn't ready yet time.sleep(0.01) continue + with self.renderer.lock: + # Account for the possibly different aspect ratio of the camera frame + # vs the display; crop any excess. + # TODO: This cropping may be unnecessary if the above TODO about the + # camera resolution is solved. + box = None + if self.canvas_width != frame.width: + half_width_diff = int(abs(self.canvas_width - frame.width)/2) + box = ( + half_width_diff, + 0, + frame.width - half_width_diff, + frame.height + ) + elif self.canvas_height != frame.height: + half_height_diff = int(abs(self.canvas_height - frame.height)/2) + box = ( + 0, + half_height_diff, + frame.width, + frame.height - half_height_diff + ) + + self.renderer.canvas.paste(frame.crop(box=box)) + # Check for ANYCLICK to take final entropy image if self.hw_inputs.check_for_low(keys=HardwareButtonsConstants.KEYS__ANYCLICK): # Have to manually update last input time since we're not in a wait_for loop @@ -51,8 +81,6 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen): self.camera.stop_video_stream_mode() with self.renderer.lock: - self.renderer.canvas.paste(frame) - self.renderer.draw.text( xy=( int(self.renderer.canvas_width/2), @@ -71,8 +99,6 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen): # If we're still here, it's just another preview frame loop with self.renderer.lock: - self.renderer.canvas.paste(frame) - self.renderer.draw.text( xy=( int(self.renderer.canvas_width/2), diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index a6828fe7..69d4bfde 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -5,7 +5,7 @@ import time from gettext import gettext as _ -from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerIconConstants +from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerIconConstants, resize_image_to_fill from seedsigner.gui.screens import RET_CODE__BACK_BUTTON, ButtonListScreen from seedsigner.gui.screens.screen import ButtonOption from seedsigner.helpers import mnemonic_generation @@ -83,21 +83,25 @@ class ToolsImageEntropyFinalImageView(View): from seedsigner.hardware.camera import Camera # Take the final full-res image camera = Camera.get_instance() - camera.start_single_frame_mode(resolution=(720, 480)) + max_dim = max(self.canvas_width, self.canvas_height) + + # Final image will be at least 4x the number of pixels the screen can + # actually display. + camera.start_single_frame_mode(resolution=(2*max_dim, 2*max_dim)) + time.sleep(0.25) self.controller.image_entropy_final_image = camera.capture_frame() camera.stop_single_frame_mode() - # Prep a copy of the image for display. The actual image data is 720x480 - # Present just a center crop and resize it to fit the screen and to keep some of - # the data hidden. - display_version = autocontrast( - self.controller.image_entropy_final_image, - cutoff=2 - ).crop( - (120, 0, 600, 480) - ).resize( - (self.canvas_width, self.canvas_height), Image.Resampling.BICUBIC + # Prep a copy of the image for display: + # * Boost the contrast for better presentation (but preserve the original pixels) + # * Resize it to fit the screen + boosted_version = autocontrast(self.controller.image_entropy_final_image, cutoff=2) + display_version = resize_image_to_fill( + boosted_version, + target_size_x=self.canvas_width, + target_size_y=self.canvas_height, + sampling_method=Image.Resampling.BICUBIC, ) ret = ToolsImageEntropyFinalImageScreen( From c0cb7014dfda7c5bc7d03b33ed9361ee0fa98d76 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 20 Apr 2025 12:03:50 -0500 Subject: [PATCH 18/26] Bugfix: SeedQR transcription UI navigation; cleanup --- src/seedsigner/gui/screens/seed_screens.py | 202 +++++++++++---------- src/seedsigner/views/seed_views.py | 12 +- tests/screenshot_generator/generator.py | 4 +- 3 files changed, 115 insertions(+), 103 deletions(-) diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index a3f24cf2..2f82465e 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -1195,68 +1195,80 @@ class SeedTranscribeSeedQRWholeQRScreen(WarningEdgesMixin, ButtonListScreen): @dataclass class SeedTranscribeSeedQRZoomedInScreen(BaseScreen): + """ + QR codes are defined by the number of "modules" (squares), e.g. 21x21 modules. + + Each module will be rendered as a square of pixels, e.g. 24x24 pixels. + + In this Screen, a "zone" will mean a square module area, e.g. 5x5 modules, that + corresponds to the SeedQR templates which include zone guidelines and labels + (e.g. "B-3"). + """ qr_data: str = None num_modules: int = None - initial_block_x: int = 0 - initial_block_y: int = 0 + initial_zone_x: int = 0 + initial_zone_y: int = 0 def __post_init__(self): super().__post_init__() # Render an oversized QR code that we can view up close - self.pixels_per_block = 24 + self.pixels_per_module = 24 # Border must accommodate the 3 blocks outside the center 5x5 mask plus up to # 1 empty block inside the 5x5 mask (29x29 has a 4-block final col/row). - self.qr_border = 4 + self.num_qr_border_modules = 4 if self.num_modules == 21: # Optimize for 21x21 - self.qr_blocks_per_zoom = 7 + self.modules_per_zone = 7 # i.e. a 7x7 group of modules else: - self.qr_blocks_per_zoom = 5 + self.modules_per_zone = 5 - self.qr_width = (self.qr_border + self.num_modules + self.qr_border) * self.pixels_per_block - self.height = self.qr_width + total_qr_image_width = (self.num_qr_border_modules + self.num_modules + self.num_qr_border_modules) * self.pixels_per_module qr = QR() - self.qr_image = qr.qrimage( + self.qr_image: Image = qr.qrimage( self.qr_data, - width=self.qr_width, - height=self.height, - border=self.qr_border, + width=total_qr_image_width, + height=total_qr_image_width, # QR image is always square + border=self.num_qr_border_modules, style=QR.STYLE__ROUNDED - ).convert("RGBA") + # ).convert("RGBA") + ) - # Render gridlines but leave the 1-block border as-is - draw = ImageDraw.Draw(self.qr_image) - for i in range(self.qr_border, math.floor(self.qr_width/self.pixels_per_block) - self.qr_border): - draw.line((i * self.pixels_per_block, self.qr_border * self.pixels_per_block, i * self.pixels_per_block, self.height - self.qr_border * self.pixels_per_block), fill="#bbb") - draw.line((self.qr_border * self.pixels_per_block, i * self.pixels_per_block, self.qr_width - self.qr_border * self.pixels_per_block, i * self.pixels_per_block), fill="#bbb") + # Render gridlines over the QR code but don't draw on its external white border + qr_image_draw = ImageDraw.Draw(self.qr_image) + for i in range(self.num_qr_border_modules, math.floor(self.qr_image.width/self.pixels_per_module) - self.num_qr_border_modules): + qr_image_draw.line((i * self.pixels_per_module, self.num_qr_border_modules * self.pixels_per_module, i * self.pixels_per_module, self.qr_image.height - self.num_qr_border_modules * self.pixels_per_module), fill="#bbb") + qr_image_draw.line((self.num_qr_border_modules * self.pixels_per_module, i * self.pixels_per_module, self.qr_image.width - self.num_qr_border_modules * self.pixels_per_module, i * self.pixels_per_module), fill="#bbb") # Prep the semi-transparent mask overlay - # make a blank image for the overlay, initialized to transparent - self.block_mask = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0)) - draw = ImageDraw.Draw(self.block_mask) + # make a blank image for the overlay, initialized to fully transparent + self.zone_mask = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0)) + zone_mask_draw = ImageDraw.Draw(self.zone_mask) - self.mask_width = int((self.canvas_width - self.qr_blocks_per_zoom * self.pixels_per_block)/2) - self.mask_height = int((self.canvas_height - self.qr_blocks_per_zoom * self.pixels_per_block)/2) + # TODO: Could reverse this to initialize the mask to `mask_rgba` but then cut out + # the fully transparent center area w/an edge line; One draw command instead of 8. + # `mask_*` vars measure the space from the screen edge to the inside mask cutout + self.mask_width = int((self.canvas_width - self.modules_per_zone * self.pixels_per_module)/2) + self.mask_height = int((self.canvas_height - self.modules_per_zone * self.pixels_per_module)/2) mask_rgba = (0, 0, 0, 226) - draw.rectangle((0, 0, self.canvas_width, self.mask_height), fill=mask_rgba) - draw.rectangle((0, self.canvas_height - self.mask_height - 1, self.canvas_width, self.canvas_height), fill=mask_rgba) - draw.rectangle((0, self.mask_height, self.mask_width, self.canvas_height - self.mask_height), fill=mask_rgba) - draw.rectangle((self.canvas_width - self.mask_width - 1, self.mask_height, self.canvas_width, self.canvas_height - self.mask_height), fill=mask_rgba) + zone_mask_draw.rectangle((0, 0, self.canvas_width, self.mask_height), fill=mask_rgba) + zone_mask_draw.rectangle((0, self.canvas_height - self.mask_height - 1, self.canvas_width, self.canvas_height), fill=mask_rgba) + zone_mask_draw.rectangle((0, self.mask_height, self.mask_width, self.canvas_height - self.mask_height), fill=mask_rgba) + zone_mask_draw.rectangle((self.canvas_width - self.mask_width - 1, self.mask_height, self.canvas_width, self.canvas_height - self.mask_height), fill=mask_rgba) # Draw a box around the cutout portion of the mask for better visibility - draw.line((self.mask_width, self.mask_height, self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR) - draw.line((self.canvas_width - self.mask_width, self.mask_height, self.canvas_width - self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR) - draw.line((self.mask_width, self.mask_height, self.canvas_width - self.mask_width, self.mask_height), fill=GUIConstants.ACCENT_COLOR) - draw.line((self.mask_width, self.canvas_height - self.mask_height, self.canvas_width - self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR) + zone_mask_draw.line((self.mask_width, self.mask_height, self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR) + zone_mask_draw.line((self.canvas_width - self.mask_width, self.mask_height, self.canvas_width - self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR) + zone_mask_draw.line((self.mask_width, self.mask_height, self.canvas_width - self.mask_width, self.mask_height), fill=GUIConstants.ACCENT_COLOR) + zone_mask_draw.line((self.mask_width, self.canvas_height - self.mask_height, self.canvas_width - self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR) msg = _("click to exit") font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_body_font_size()) (left, top, right, bottom) = font.getbbox(msg, anchor="ls") msg_height = -1 * top + GUIConstants.COMPONENT_PADDING msg_width = right + 2*GUIConstants.COMPONENT_PADDING - draw.rectangle( + zone_mask_draw.rectangle( ( int((self.canvas_width - msg_width)/2), self.canvas_height - msg_height, @@ -1265,7 +1277,7 @@ class SeedTranscribeSeedQRZoomedInScreen(BaseScreen): ), fill=GUIConstants.BACKGROUND_COLOR, ) - draw.text( + zone_mask_draw.text( (int(self.canvas_width/2), self.canvas_height - int(GUIConstants.COMPONENT_PADDING/2)), msg, fill=GUIConstants.BODY_FONT_COLOR, @@ -1275,103 +1287,103 @@ class SeedTranscribeSeedQRZoomedInScreen(BaseScreen): - def draw_block_labels(self): - # Create overlay for block labels (e.g. "D-5") - block_labels_x = ["1", "2", "3", "4", "5", "6"] - block_labels_y = ["A", "B", "C", "D", "E", "F"] + def draw_zone_labels(self): + # Create overlay for zone labels (e.g. "D-5") + # TODO: Discuss w/translators if these zone labels need to be translated; would + # trigger a secondary need to have translated SeedQR printable templates as well. + zone_labels_x = ["1", "2", "3", "4", "5", "6"] + zone_labels_y = ["A", "B", "C", "D", "E", "F"] - block_labels = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0)) - draw = ImageDraw.Draw(block_labels) - draw.rectangle((self.mask_width, 0, self.canvas_width - self.mask_width, self.pixels_per_block), fill=GUIConstants.ACCENT_COLOR) - draw.rectangle((0, self.mask_height, self.pixels_per_block, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR) + zone_labels = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0)) + zone_labels_draw = ImageDraw.Draw(zone_labels) + zone_labels_draw.rectangle((self.mask_width, 0, self.canvas_width - self.mask_width, self.pixels_per_module), fill=GUIConstants.ACCENT_COLOR) + zone_labels_draw.rectangle((0, self.mask_height, self.pixels_per_module, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR) label_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, 28) - x_label = block_labels_x[self.cur_block_x] + x_label = zone_labels_x[self.cur_zone_x] (left, top, right, bottom) = label_font.getbbox(x_label, anchor="ls") x_label_height = -1 * top - draw.text( - (int(self.canvas_width/2), self.pixels_per_block - int((self.pixels_per_block - x_label_height)/2)), + zone_labels_draw.text( + (int(self.canvas_width/2), self.pixels_per_module - int((self.pixels_per_module - x_label_height)/2)), text=x_label, fill=GUIConstants.BUTTON_SELECTED_FONT_COLOR, font=label_font, anchor="ms", # Middle, baSeline ) - y_label = block_labels_y[self.cur_block_y] + y_label = zone_labels_y[self.cur_zone_y] (left, top, right, bottom) = label_font.getbbox(y_label, anchor="ls") y_label_height = -1 * top - draw.text( - (int(self.pixels_per_block/2), int((self.canvas_height + y_label_height) / 2)), + zone_labels_draw.text( + (int(self.pixels_per_module/2), int((self.canvas_height + y_label_height) / 2)), text=y_label, fill=GUIConstants.BUTTON_SELECTED_FONT_COLOR, font=label_font, anchor="ms", # Middle, baSeline ) - return block_labels + return zone_labels def _render(self): - # Track our current coordinates for the upper left corner of our view - self.cur_block_x = self.initial_block_x - self.cur_block_y = self.initial_block_y - self.cur_x = (self.cur_block_x * self.qr_blocks_per_zoom * self.pixels_per_block) + self.qr_border * self.pixels_per_block - self.mask_width - self.cur_y = (self.cur_block_y * self.qr_blocks_per_zoom * self.pixels_per_block) + self.qr_border * self.pixels_per_block - self.mask_height - self.next_x = self.cur_x - self.next_y = self.cur_y + # Track our current zone-level (macro-module) position and our actual pixel + # coordinates as we pan around across the QR code image. + self.cur_zone_x = self.initial_zone_x + self.cur_zone_y = self.initial_zone_y + self.cur_pixel_x = (self.cur_zone_x * self.modules_per_zone * self.pixels_per_module) + self.num_qr_border_modules * self.pixels_per_module - self.mask_width + self.cur_pixel_y = (self.cur_zone_y * self.modules_per_zone * self.pixels_per_module) + self.num_qr_border_modules * self.pixels_per_module - self.mask_height + self.next_pixel_x = self.cur_pixel_x + self.next_pixel_y = self.cur_pixel_y - block_labels = self.draw_block_labels() + zone_labels = self.draw_zone_labels() self.renderer.show_image( - self.qr_image.crop((self.cur_x, self.cur_y, self.cur_x + self.canvas_width, self.cur_y + self.canvas_height)), - alpha_overlay=Image.alpha_composite(self.block_mask, block_labels) + self.qr_image.crop((self.cur_pixel_x, self.cur_pixel_y, self.cur_pixel_x + self.canvas_width, self.cur_pixel_y + self.canvas_height)), + alpha_overlay=Image.alpha_composite(self.zone_mask, zone_labels) ) def _run(self): while True: input = self.hw_inputs.wait_for(HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN + HardwareButtonsConstants.KEYS__ANYCLICK) - if input == HardwareButtonsConstants.KEY_RIGHT: - self.next_x = self.cur_x + self.qr_blocks_per_zoom * self.pixels_per_block - self.cur_block_x += 1 - if self.next_x > self.qr_width - self.canvas_width: - self.next_x = self.cur_x - self.cur_block_x -= 1 - elif input == HardwareButtonsConstants.KEY_LEFT: - self.next_x = self.cur_x - self.qr_blocks_per_zoom * self.pixels_per_block - self.cur_block_x -= 1 - if self.next_x < 0: - self.next_x = self.cur_x - self.cur_block_x += 1 - elif input == HardwareButtonsConstants.KEY_DOWN: - self.next_y = self.cur_y + self.qr_blocks_per_zoom * self.pixels_per_block - self.cur_block_y += 1 - if self.next_y > self.height - self.canvas_height: - self.next_y = self.cur_y - self.cur_block_y -= 1 - elif input == HardwareButtonsConstants.KEY_UP: - self.next_y = self.cur_y - self.qr_blocks_per_zoom * self.pixels_per_block - self.cur_block_y -= 1 - if self.next_y < 0: - self.next_y = self.cur_y - self.cur_block_y += 1 - elif input in HardwareButtonsConstants.KEYS__ANYCLICK: + + if input in HardwareButtonsConstants.KEYS__ANYCLICK: + # User clicked to exit return - # Create overlay for block labels (e.g. "D-5") - block_labels = self.draw_block_labels() + elif input == HardwareButtonsConstants.KEY_RIGHT and self.cur_zone_x + 1 < math.ceil(self.num_modules/self.modules_per_zone): + self.next_pixel_x = self.cur_pixel_x + self.modules_per_zone * self.pixels_per_module + self.cur_zone_x += 1 - if self.cur_x != self.next_x or self.cur_y != self.next_y: - with self.renderer.lock: - self.renderer.show_image_pan( - self.qr_image, - self.cur_x, self.cur_y, self.next_x, self.next_y, - rate=self.pixels_per_block, - alpha_overlay=Image.alpha_composite(self.block_mask, block_labels) - ) - self.cur_x = self.next_x - self.cur_y = self.next_y + elif input == HardwareButtonsConstants.KEY_LEFT and self.cur_zone_x - 1 >= 0: + self.next_pixel_x = self.cur_pixel_x - self.modules_per_zone * self.pixels_per_module + self.cur_zone_x -= 1 + + elif input == HardwareButtonsConstants.KEY_DOWN and self.cur_zone_y + 1 < math.ceil(self.num_modules/self.modules_per_zone): + self.next_pixel_y = self.cur_pixel_y + self.modules_per_zone * self.pixels_per_module + self.cur_zone_y += 1 + + elif input == HardwareButtonsConstants.KEY_UP and self.cur_zone_y - 1 >= 0: + self.next_pixel_y = self.cur_pixel_y - self.modules_per_zone * self.pixels_per_module + self.cur_zone_y -= 1 + + else: + # User selected a direction that we can't advance any further + continue + + # Create overlay for zone labels (e.g. "D-5") + zone_labels = self.draw_zone_labels() + + with self.renderer.lock: + self.renderer.show_image_pan( + self.qr_image, + self.cur_pixel_x, self.cur_pixel_y, self.next_pixel_x, self.next_pixel_y, + rate=self.pixels_per_module, + alpha_overlay=Image.alpha_composite(self.zone_mask, zone_labels) + ) + self.cur_pixel_x = self.next_pixel_x + self.cur_pixel_y = self.next_pixel_y diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 6ce7e163..b9b85f38 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -1558,16 +1558,16 @@ class SeedTranscribeSeedQRWholeQRView(View): class SeedTranscribeSeedQRZoomedInView(View): """ - intial_block_x, initial_block_y: Used by the screenshot generator to shift the view + intial_zone_x, initial_zone_y: Used by the screenshot generator to shift the view to a more interesting part of the QR code template. """ - def __init__(self, seed_num: int, seedqr_format: str, initial_block_x: int = 0, initial_block_y: int = 0): + def __init__(self, seed_num: int, seedqr_format: str, initial_zone_x: int = 0, initial_zone_y: int = 0): super().__init__() self.seed_num = seed_num self.seedqr_format = seedqr_format self.seed = self.controller.get_seed(seed_num) - self.initial_block_x = initial_block_x - self.initial_block_y = initial_block_y + self.initial_zone_x = initial_zone_x + self.initial_zone_y = initial_zone_y def run(self): @@ -1594,8 +1594,8 @@ class SeedTranscribeSeedQRZoomedInView(View): seed_screens.SeedTranscribeSeedQRZoomedInScreen( qr_data=data, num_modules=num_modules, - initial_block_x=self.initial_block_x, - initial_block_y=self.initial_block_y, + initial_zone_x=self.initial_zone_x, + initial_zone_y=self.initial_zone_y, ).display() return Destination(SeedTranscribeSeedQRConfirmQRPromptView, view_args={"seed_num": self.seed_num}) diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 5234ae1d..60b470e9 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -321,8 +321,8 @@ def generate_screenshots(locale): ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_12_Standard"), ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Compact"), ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__SEEDQR, num_modules=29), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Standard"), - ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, initial_block_x=1, initial_block_y=1), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Compact"), - ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, initial_block_x=2, initial_block_y=2), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Standard"), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, initial_zone_x=1, initial_zone_y=1), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Compact"), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, initial_zone_x=2, initial_zone_y=2), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Standard"), ScreenshotConfig(seed_views.SeedTranscribeSeedQRConfirmQRPromptView, dict(seed_num=0)), ScreenshotConfig(seed_views.SeedTranscribeSeedQRConfirmWrongSeedView), From 9500059cc52353a7043d8fc6c675952320b71fad Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 20 Apr 2025 12:23:16 -0500 Subject: [PATCH 19/26] simplified, optimized SeedQR transcription mask overlay rendering --- src/seedsigner/gui/screens/seed_screens.py | 47 +++++++++++----------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index 2f82465e..e390fa15 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -1241,27 +1241,28 @@ class SeedTranscribeSeedQRZoomedInScreen(BaseScreen): qr_image_draw.line((i * self.pixels_per_module, self.num_qr_border_modules * self.pixels_per_module, i * self.pixels_per_module, self.qr_image.height - self.num_qr_border_modules * self.pixels_per_module), fill="#bbb") qr_image_draw.line((self.num_qr_border_modules * self.pixels_per_module, i * self.pixels_per_module, self.qr_image.width - self.num_qr_border_modules * self.pixels_per_module, i * self.pixels_per_module), fill="#bbb") - # Prep the semi-transparent mask overlay - # make a blank image for the overlay, initialized to fully transparent - self.zone_mask = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0)) + # Make a blank semi-transparent image for the overlay, initially across the + # entire canvas. + mask_rgba = (0, 0, 0, 226) + self.zone_mask = Image.new("RGBA", (self.canvas_width, self.canvas_height), mask_rgba) zone_mask_draw = ImageDraw.Draw(self.zone_mask) - # TODO: Could reverse this to initialize the mask to `mask_rgba` but then cut out - # the fully transparent center area w/an edge line; One draw command instead of 8. - # `mask_*` vars measure the space from the screen edge to the inside mask cutout - self.mask_width = int((self.canvas_width - self.modules_per_zone * self.pixels_per_module)/2) - self.mask_height = int((self.canvas_height - self.modules_per_zone * self.pixels_per_module)/2) - mask_rgba = (0, 0, 0, 226) - zone_mask_draw.rectangle((0, 0, self.canvas_width, self.mask_height), fill=mask_rgba) - zone_mask_draw.rectangle((0, self.canvas_height - self.mask_height - 1, self.canvas_width, self.canvas_height), fill=mask_rgba) - zone_mask_draw.rectangle((0, self.mask_height, self.mask_width, self.canvas_height - self.mask_height), fill=mask_rgba) - zone_mask_draw.rectangle((self.canvas_width - self.mask_width - 1, self.mask_height, self.canvas_width, self.canvas_height - self.mask_height), fill=mask_rgba) - - # Draw a box around the cutout portion of the mask for better visibility - zone_mask_draw.line((self.mask_width, self.mask_height, self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR) - zone_mask_draw.line((self.canvas_width - self.mask_width, self.mask_height, self.canvas_width - self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR) - zone_mask_draw.line((self.mask_width, self.mask_height, self.canvas_width - self.mask_width, self.mask_height), fill=GUIConstants.ACCENT_COLOR) - zone_mask_draw.line((self.mask_width, self.canvas_height - self.mask_height, self.canvas_width - self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR) + # Now punch a hole in the center of the mask to highlight the current zone with + # an accent outline. + # The `zone_mask_offset_*` vars are the top left xy coords of the mask. + self.zone_mask_offset_x = int((self.canvas_width - (self.modules_per_zone * self.pixels_per_module))/2) + self.zone_mask_offset_y = int((self.canvas_height - (self.modules_per_zone * self.pixels_per_module))/2) + zone_mask_draw.rectangle( + ( + self.zone_mask_offset_x, + self.zone_mask_offset_y, + self.canvas_width - self.zone_mask_offset_x, + self.canvas_height - self.zone_mask_offset_y + ), + fill=(255, 255, 255, 0), # fully transparent mask area + outline=GUIConstants.ACCENT_COLOR, + width=1 + ) msg = _("click to exit") font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_body_font_size()) @@ -1296,8 +1297,8 @@ class SeedTranscribeSeedQRZoomedInScreen(BaseScreen): zone_labels = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0)) zone_labels_draw = ImageDraw.Draw(zone_labels) - zone_labels_draw.rectangle((self.mask_width, 0, self.canvas_width - self.mask_width, self.pixels_per_module), fill=GUIConstants.ACCENT_COLOR) - zone_labels_draw.rectangle((0, self.mask_height, self.pixels_per_module, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR) + zone_labels_draw.rectangle((self.zone_mask_offset_x, 0, self.canvas_width - self.zone_mask_offset_x, self.pixels_per_module), fill=GUIConstants.ACCENT_COLOR) + zone_labels_draw.rectangle((0, self.zone_mask_offset_y, self.pixels_per_module, self.canvas_height - self.zone_mask_offset_y), fill=GUIConstants.ACCENT_COLOR) label_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, 28) x_label = zone_labels_x[self.cur_zone_x] @@ -1331,8 +1332,8 @@ class SeedTranscribeSeedQRZoomedInScreen(BaseScreen): # coordinates as we pan around across the QR code image. self.cur_zone_x = self.initial_zone_x self.cur_zone_y = self.initial_zone_y - self.cur_pixel_x = (self.cur_zone_x * self.modules_per_zone * self.pixels_per_module) + self.num_qr_border_modules * self.pixels_per_module - self.mask_width - self.cur_pixel_y = (self.cur_zone_y * self.modules_per_zone * self.pixels_per_module) + self.num_qr_border_modules * self.pixels_per_module - self.mask_height + self.cur_pixel_x = (self.cur_zone_x * self.modules_per_zone * self.pixels_per_module) + self.num_qr_border_modules * self.pixels_per_module - self.zone_mask_offset_x + self.cur_pixel_y = (self.cur_zone_y * self.modules_per_zone * self.pixels_per_module) + self.num_qr_border_modules * self.pixels_per_module - self.zone_mask_offset_y self.next_pixel_x = self.cur_pixel_x self.next_pixel_y = self.cur_pixel_y From 1116c9e805b9c4d287c3fb2e9c28da15af74358b Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 20 Apr 2025 13:40:59 -0500 Subject: [PATCH 20/26] Finish integrating ToolsAddressExplorerAddressListScreen --- src/seedsigner/gui/screens/tools_screens.py | 21 ++++++++++------ src/seedsigner/views/tools_views.py | 27 +++------------------ 2 files changed, 18 insertions(+), 30 deletions(-) diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py index 7e8c777b..cc16002d 100644 --- a/src/seedsigner/gui/screens/tools_screens.py +++ b/src/seedsigner/gui/screens/tools_screens.py @@ -8,7 +8,7 @@ from seedsigner.gui.renderer import Renderer from seedsigner.hardware.camera import Camera from seedsigner.gui.components import FontAwesomeIconConstants, Fonts, GUIConstants, IconTextLine, SeedSignerIconConstants, TextArea -from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen, ButtonListScreen, KeyboardScreen +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen, ButtonListScreen, ButtonOption, KeyboardScreen from seedsigner.hardware.buttons import HardwareButtonsConstants from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition @@ -496,19 +496,18 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen): 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.button_font_size = GUIConstants.get_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)) + last_addr_index = self.start_index + len(self.addresses) - 1 + index_digits = len(str(last_addr_index)) # Calculate how many pixels we have available within each address button, # remembering to account for the index number that will be displayed. @@ -521,8 +520,16 @@ class ToolsAddressExplorerAddressListScreen(ButtonListScreen): 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:]}") + + # TODO: Intentionally NOT marking these for translation, but we may need to in + # the future. + button_label = f"{cur_index}:{address[:displayable_half]}...{address[-1*displayable_half:]}" + active_button_label = f"{cur_index}:{address}" + + self.button_data.append(ButtonOption(button_label, active_button_label=active_button_label)) - self.button_data.append(self.next_button) + # TRANSLATOR_NOTE: Insert the number of addrs displayed per screen (e.g. "Next 10") + button_label = _("Next {}").format(len(self.addresses)) + self.button_data.append(ButtonOption(button_label, right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT)) super().__post_init__() diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index 69d4bfde..1819b272 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -623,6 +623,7 @@ class ToolsAddressExplorerAddressListView(View): def run(self): + from seedsigner.gui.screens.tools_screens import ToolsAddressExplorerAddressListScreen self.loading_screen = None addresses = [] @@ -676,31 +677,11 @@ class ToolsAddressExplorerAddressListView(View): # 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(ButtonOption(f"{cur_index}:{address[:8]}...{address[end_digits:]}", active_button_label=f"{cur_index}:{address}")) - - # TRANSLATOR_NOTE: Insert the number of addrs displayed per screen (e.g. "Next 10") - button_label = _("Next {}").format(addrs_per_screen) - button_data.append(ButtonOption(button_label, right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT)) - selected_menu_num = self.run_screen( - ButtonListScreen, + ToolsAddressExplorerAddressListScreen, title=_("Receive Addrs") if not self.is_change else _("Change Addrs"), - button_data=button_data, - button_font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, - button_font_size=GUIConstants.get_button_font_size() + 4, - is_button_text_centered=False, - is_bottom_list=True, + start_index=self.start_index, + addresses=addresses, selected_button=self.selected_button_index, scroll_y_initial_offset=self.initial_scroll, ) From 499c82dce03bbc96b094932089692108cf230907 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 20 Apr 2025 14:47:45 -0500 Subject: [PATCH 21/26] standardize on our landscape orientation (widest dim first) --- src/seedsigner/models/settings_definition.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index b2dbf1d3..5e1e33df 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -345,14 +345,14 @@ class SettingsConstants: # 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 + DISPLAY_CONFIGURATION__ST7789__320x240 = "st7789_240x320" + DISPLAY_CONFIGURATION__ILI9341__320x240 = "ili9341_320x240" # natively portrait dimensions; we apply a 90° rotation + DISPLAY_CONFIGURATION__ILI9486__480x320 = "ili9486_480x320" # 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 + (DISPLAY_CONFIGURATION__ST7789__320x240, "st7789 320x240"), + (DISPLAY_CONFIGURATION__ILI9341__320x240, "ili9341 320x240"), + # (DISPLAY_CONFIGURATION__ILI9486__320x480, "ili9486 480x320"), # TODO: Enable when ili9486 driver performance is improved ] From 052035d058e752f96cc098401647230a183ff94b Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 20 Apr 2025 14:49:07 -0500 Subject: [PATCH 22/26] Fix up one last value --- src/seedsigner/models/settings_definition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 5e1e33df..2b6214a7 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -345,7 +345,7 @@ class SettingsConstants: # Hardware config settings DISPLAY_CONFIGURATION__ST7789__240x240 = "st7789_240x240" # default; original Waveshare 1.3" display hat - DISPLAY_CONFIGURATION__ST7789__320x240 = "st7789_240x320" + DISPLAY_CONFIGURATION__ST7789__320x240 = "st7789_320x240" # natively portrait dimensions; we apply a 90° rotation DISPLAY_CONFIGURATION__ILI9341__320x240 = "ili9341_320x240" # natively portrait dimensions; we apply a 90° rotation DISPLAY_CONFIGURATION__ILI9486__480x320 = "ili9486_480x320" # natively portrait dimensions; we apply a 90° rotation ALL_DISPLAY_CONFIGURATIONS = [ From 7f53c7cd3fb678f9c014911b5e9115de8d874131 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 20 Apr 2025 14:53:57 -0500 Subject: [PATCH 23/26] bugfix after swapping st7789 dims --- src/seedsigner/hardware/displays/display_driver.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/seedsigner/hardware/displays/display_driver.py b/src/seedsigner/hardware/displays/display_driver.py index 0d5aee4e..a41bb321 100644 --- a/src/seedsigner/hardware/displays/display_driver.py +++ b/src/seedsigner/hardware/displays/display_driver.py @@ -12,19 +12,20 @@ class DisplayDriver: self.display_type = display_type if self.display_type == DISPLAY_TYPE__ST7789: - if height not in [240, 320] or width != 240: + if width not in [240, 320] or height != 240: raise ValueError("ST7789 display only supports 240x240 or 320x240 resolutions") - if height == 240: + if width == 240: # TODO: For now the original ST7789 driver has to be used for 240x240. # The mpy version below renders incorrectly (almost like each row of pixels # is one pixel short, so the entire screen exhibits a diagonal skew). from seedsigner.hardware.displays.ST7789 import ST7789 self.display = ST7789() - elif height == 320: + elif width == 320: from seedsigner.hardware.displays.st7789_mpy import ST7789 - self.display = ST7789(width=width, height=height) + # Have to swap width and height; screen is natively 240x320 + self.display = ST7789(width=height, height=width) elif self.display_type == DISPLAY_TYPE__ILI9341: from seedsigner.hardware.displays.ili9341 import ILI9341 From 6887c4f62f96a37add97f59c4d0d6bf7d31d74a4 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Tue, 22 Apr 2025 08:36:07 -0500 Subject: [PATCH 24/26] Add `invert` to `ST7789` driver --- src/seedsigner/hardware/displays/ST7789.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/hardware/displays/ST7789.py b/src/seedsigner/hardware/displays/ST7789.py index ee272020..67d91755 100644 --- a/src/seedsigner/hardware/displays/ST7789.py +++ b/src/seedsigner/hardware/displays/ST7789.py @@ -114,7 +114,7 @@ class ST7789(object): self.data(0x20) self.data(0x23) - self.command(0x21) + self.command(0x21) # inversion ON; 0x20 = inversion OFF self.command(0x11) @@ -166,4 +166,8 @@ class ST7789(object): _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) \ No newline at end of file + self._spi.writebytes2(_buffer) + + def invert(self, enabled: bool = True): + """Invert how the display interprets colors""" + self.command(0x21 if enabled else 0x20) From d8c2833d6e1adc9111aa331a16d96a94a264d168 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Tue, 22 Apr 2025 08:36:25 -0500 Subject: [PATCH 25/26] Add "beta" designation to ili9341 driver --- src/seedsigner/models/settings_definition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 2b6214a7..d9272428 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -351,7 +351,7 @@ class SettingsConstants: ALL_DISPLAY_CONFIGURATIONS = [ (DISPLAY_CONFIGURATION__ST7789__240x240, "st7789 240x240"), (DISPLAY_CONFIGURATION__ST7789__320x240, "st7789 320x240"), - (DISPLAY_CONFIGURATION__ILI9341__320x240, "ili9341 320x240"), + (DISPLAY_CONFIGURATION__ILI9341__320x240, "ili9341 320x240 (beta)"), # (DISPLAY_CONFIGURATION__ILI9486__320x480, "ili9486 480x320"), # TODO: Enable when ili9486 driver performance is improved ] From e00f0a2db6abae316636bc0783726dfb67a10d4e Mon Sep 17 00:00:00 2001 From: kdmukai Date: Tue, 22 Apr 2025 08:54:29 -0500 Subject: [PATCH 26/26] Reorder Settings screenshots, remove 320x240 hard coding --- tests/screenshot_generator/generator.py | 57 +++++++++++-------------- tests/screenshot_generator/utils.py | 2 +- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 60b470e9..bd877174 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -162,34 +162,45 @@ def generate_screenshots(locale): "addr_format": embit_utils.parse_derivation_path(derivation_path) } + # so we get a choice for transcribe seed qr format + controller.settings.set_value( + attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR, + value=SettingsConstants.OPTION__ENABLED + ) + # Automatically populate all Settings options Views settings_views_list = [] + def add_settings_entries(visibility = SettingsConstants.VISIBILITY__GENERAL): + for settings_entry in SettingsDefinition.settings_entries: + if settings_entry.visibility != visibility: + continue + + if settings_entry.attr_name == SettingsConstants.SETTING__LOCALE: + # Locale selection has its own dedicated View + settings_views_list.append(ScreenshotConfig(settings_views.LocaleSelectionView)) + else: + # Generic SettingsEntry selection View + settings_views_list.append(ScreenshotConfig(settings_views.SettingsEntryUpdateSelectionView, dict(attr_name=settings_entry.attr_name), screenshot_name=f"SettingsEntryUpdateSelectionView_{settings_entry.attr_name}")) + + # Add the top level "General" settings menu and entries settings_views_list.append(ScreenshotConfig(settings_views.SettingsMenuView)) + add_settings_entries(SettingsConstants.VISIBILITY__GENERAL) + + # Add the "Advanced" menu... settings_views_list.append( ScreenshotConfig( settings_views.SettingsMenuView, dict( visibility=SettingsConstants.VISIBILITY__ADVANCED, - selected_attr=SettingsConstants.SETTING__ELECTRUM_SEEDS, ), screenshot_name="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( - ScreenshotConfig( - 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 - ), - screenshot_name="SettingsMenuView__Advanced_Hardware" - ) - ) + # ...and Advanced entries + add_settings_entries(SettingsConstants.VISIBILITY__ADVANCED) + # Render the nested "Advanced" -> "Hardware" submenu settings_views_list.append( ScreenshotConfig( settings_views.SettingsMenuView, @@ -197,27 +208,11 @@ def generate_screenshots(locale): screenshot_name="SettingsMenuView__Hardware" ) ) - - # so we get a choice for transcribe seed qr format - controller.settings.set_value( - attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR, - value=SettingsConstants.OPTION__ENABLED - ) - for settings_entry in SettingsDefinition.settings_entries: - if settings_entry.visibility == SettingsConstants.VISIBILITY__HIDDEN: - continue - - if settings_entry.attr_name == SettingsConstants.SETTING__LOCALE: - # Locale selection has its own dedicated View - settings_views_list.append(ScreenshotConfig(settings_views.LocaleSelectionView)) - else: - # Generic SettingsEntry selection View - settings_views_list.append(ScreenshotConfig(settings_views.SettingsEntryUpdateSelectionView, dict(attr_name=settings_entry.attr_name), screenshot_name=f"SettingsEntryUpdateSelectionView_{settings_entry.attr_name}")) + add_settings_entries(SettingsConstants.VISIBILITY__HARDWARE) settingsqr_data_persistent = f"settings::v1 name=English_noob_mode persistent=E coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E locale={locale}" settingsqr_data_not_persistent = f"settings::v1 name=Mode_Ephemeral persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E locale={locale}" - # Set up screenshot-specific callbacks to inject data before the View is run and # reset data after the View is run. def load_basic_psbt_cb(): diff --git a/tests/screenshot_generator/utils.py b/tests/screenshot_generator/utils.py index e818fee5..d767bdf2 100644 --- a/tests/screenshot_generator/utils.py +++ b/tests/screenshot_generator/utils.py @@ -25,7 +25,7 @@ class ScreenshotRenderer(Renderer): cls._instance = renderer # Hard-coding output values for now - renderer.canvas_width = 320 + renderer.canvas_width = 240 renderer.canvas_height = 240 renderer.canvas = Image.new('RGB', (renderer.canvas_width, renderer.canvas_height))