diff --git a/CHANGELOG.md b/CHANGELOG.md index 67224a796..beeac059a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog 26.05.0 - May 2025 ### Stackbit 1248 Vertical Layout -Added vertical layout option for Stackbit 1248 backup display, allowing users to choose between Standard (horizontal) and Vertical (transposed) grid orientations. +- Added vertical layout option for Stackbit 1248 backup display/mnemonic input, allowing users to choose between Standard (horizontal) and Vertical (transposed) grid orientations. ### Migrate UR encoding to uUR MicroPython C module Switch from the pure-Python urtypes and foundation-ur-py packages to the new uUR C module, allowing faster UR QR codes decoding with a smaller RAM footprint. diff --git a/docs/getting-started/usage/loading-a-mnemonic.en.md b/docs/getting-started/usage/loading-a-mnemonic.en.md index cef1e0af6..3fb5ad380 100644 --- a/docs/getting-started/usage/loading-a-mnemonic.en.md +++ b/docs/getting-started/usage/loading-a-mnemonic.en.md @@ -80,6 +80,12 @@ Enter the BIP39 mnemonic word's numbers (1-2048) in binary format, toggling nece Enter the BIP39 mnemonic word's numbers (1-2048) using the Stackbit 1248 metal plate backup method, where each of the four digits of the word's number is a sum of the numbers marked (punched) 1, 2, 4, or 8. For example, to enter the word "oyster", number 1268, you must punch (1)(2)(2,4)(8). +
+ + + +Vertical layout transposes the grid, with rows = weights (1,2,4,8) and columns = digits. +
### From Storage diff --git a/docs/img/maixpy_amigo/load-mnemonic-via-stackbit-vertical-filled-300.png b/docs/img/maixpy_amigo/load-mnemonic-via-stackbit-vertical-filled-300.png new file mode 100644 index 000000000..2b8066a24 Binary files /dev/null and b/docs/img/maixpy_amigo/load-mnemonic-via-stackbit-vertical-filled-300.png differ diff --git a/docs/img/maixpy_amigo/load-mnemonic-via-stackbit-vertical-initial-300.png b/docs/img/maixpy_amigo/load-mnemonic-via-stackbit-vertical-initial-300.png new file mode 100644 index 000000000..fba9b6493 Binary files /dev/null and b/docs/img/maixpy_amigo/load-mnemonic-via-stackbit-vertical-initial-300.png differ diff --git a/docs/img/maixpy_m5stickv/load-mnemonic-via-stackbit-vertical-filled-250.png b/docs/img/maixpy_m5stickv/load-mnemonic-via-stackbit-vertical-filled-250.png new file mode 100644 index 000000000..b27afa45b Binary files /dev/null and b/docs/img/maixpy_m5stickv/load-mnemonic-via-stackbit-vertical-filled-250.png differ diff --git a/docs/img/maixpy_m5stickv/load-mnemonic-via-stackbit-vertical-initial-250.png b/docs/img/maixpy_m5stickv/load-mnemonic-via-stackbit-vertical-initial-250.png new file mode 100644 index 000000000..50fa64d80 Binary files /dev/null and b/docs/img/maixpy_m5stickv/load-mnemonic-via-stackbit-vertical-initial-250.png differ diff --git a/simulator/sequences/load-mnemonic-via-stackbit.txt b/simulator/sequences/load-mnemonic-via-stackbit.txt index 2110f65d5..c4ec9f7ee 100644 --- a/simulator/sequences/load-mnemonic-via-stackbit.txt +++ b/simulator/sequences/load-mnemonic-via-stackbit.txt @@ -24,3 +24,27 @@ x5 press BUTTON_B press BUTTON_A #screenshot load-mnemonic-via-stackbit-filled.png + +# Go back to menu +press BUTTON_B +x2 press BUTTON_A + +# Navigate to Vertical +press BUTTON_B +press BUTTON_A + +screenshot load-mnemonic-via-stackbit-vertical-initial.png + +# Fill word 1 +press BUTTON_B +press BUTTON_A +x2 press BUTTON_B +press BUTTON_A +x3 press BUTTON_B +press BUTTON_A +x5 press BUTTON_B +press BUTTON_A +x2 press BUTTON_B +press BUTTON_A + +screenshot load-mnemonic-via-stackbit-vertical-filled.png diff --git a/src/krux/display.py b/src/krux/display.py index fac23bb7f..3d3811c0e 100644 --- a/src/krux/display.py +++ b/src/krux/display.py @@ -382,6 +382,12 @@ def fill_rectangle(self, x, y, width, height, color, radius=0): x -= width lcd.fill_rectangle(x, y, width, height, color, radius) + def draw_circle(self, x, y, radius, color=theme.fg_color): + """Draws a filled circle to the screen""" + if self.flipped_x_coordinates: + x = self.width() - x - 1 + lcd.draw_circle(x, y, radius, 0, color) + def draw_line(self, x_0, y_0, x_1, y_1, color=theme.fg_color): """Draws a line to the screen""" if self.flipped_x_coordinates: diff --git a/src/krux/pages/mnemonic_loader.py b/src/krux/pages/mnemonic_loader.py index 8ecbceb4c..30348ea49 100644 --- a/src/krux/pages/mnemonic_loader.py +++ b/src/krux/pages/mnemonic_loader.py @@ -257,10 +257,24 @@ def possible_letters(prefix): def load_key_from_1248(self): """Menu handler to load key from Stackbit 1248 sheet metal storage method""" + submenu = Menu( + self.ctx, + [ + (t("Standard"), self._load_key_from_1248), + (t("Vertical"), lambda: self._load_key_from_1248(vertical=True)), + ], + ) + index, status = submenu.run_loop() + if index == submenu.back_index: + return MENU_CONTINUE + return status + + def _load_key_from_1248(self, vertical=False): + """Load key from Stackbit 1248 layout (horizontal, or vertical when flag set)""" from .stack_1248 import Stackbit stackbit = Stackbit(self.ctx) - words = stackbit.enter_1248() + words = stackbit.enter_1248_vertical() if vertical else stackbit.enter_1248() del stackbit if words is not None: return self._load_key_from_words(words) diff --git a/src/krux/pages/stack_1248.py b/src/krux/pages/stack_1248.py index a86b17b63..55d6516e0 100644 --- a/src/krux/pages/stack_1248.py +++ b/src/krux/pages/stack_1248.py @@ -41,9 +41,16 @@ BIT_WEIGHTS = (1, 2, 4, 8) BIT_LABELS = ("1", "2", "4", "8") +VERT_N_DIGITS = 4 +VERT_N_BITS = 4 +VERT_N_CELLS = VERT_N_DIGITS * VERT_N_BITS +VERT_ESC_INDEX = VERT_N_CELLS +VERT_GO_INDEX = VERT_N_CELLS + 2 +VERT_INVALID_CELLS = tuple(VERT_N_DIGITS * i for i in (2, 3)) + class Stackbit(Page): - """Class for handling Stackbit 1248 fomat""" + """Class for handling Stackbit 1248 format""" def __init__(self, ctx): super().__init__(ctx, None) @@ -714,3 +721,279 @@ def enter_1248(self): if len(words) in (12, 24): return words return None + + def _layout_vertical(self, pad=DEFAULT_PADDING): + """Return geometry constants for the vertical input grid.""" + # All positions are derived from two base measures so the layout + # scales cleanly across Amigo (320×480), Dock (240×320) and + # M5StickV (135×240). + label_w = FONT_WIDTH + pad # breathing room between border and labels + gap = FONT_HEIGHT // 2 + + # Cell size: largest square that fits both the available width and the + # available height, so the grid uses the full screen on every device. + available_w = self.ctx.display.width() - 2 * pad - label_w + cell_w = available_w // VERT_N_DIGITS + max_cell_h = (self.ctx.display.height() - 4 * FONT_HEIGHT - pad - gap) // ( + VERT_N_BITS + 1 + ) + cell_size = min(cell_w, max(max_cell_h, FONT_HEIGHT)) + + grid_w = VERT_N_DIGITS * cell_size + grid_h = VERT_N_BITS * cell_size + + # Center the full block (labels + grid) horizontally on the display + total_w = label_w + grid_w + x_label = max(pad, (self.ctx.display.width() - total_w) // 2) + x_grid = x_label + label_w + + total_h = 2 * FONT_HEIGHT + grid_h + gap + FONT_HEIGHT + cell_size + y_start = max(pad, (self.ctx.display.height() - total_h) // 2) + y_grid = y_start + 2 * FONT_HEIGHT + y_preview = y_grid + grid_h + gap + y_menu = y_preview + FONT_HEIGHT + + return ( + x_grid, + y_grid, + y_start, + y_menu, + cell_size, + cell_size, + grid_w, + x_label, + y_preview, + ) + + def _map_keys_array_vertical(self, x_grid, y_grid, cell_w, cell_h, y_menu): + """Set touch regions for the 4 × 4 grid plus the Esc / Go menu row.""" + if not kboard.has_touchscreen: + return + self.ctx.input.touch.clear_regions() + x = x_grid + for _ in range(VERT_N_DIGITS + 1): + self.ctx.input.touch.x_regions.append(x) + x += cell_w + y = y_grid + for _ in range(VERT_N_BITS): + self.ctx.input.touch.y_regions.append(y) + y += cell_h + self.ctx.input.touch.y_regions.append(y_menu) + self.ctx.input.touch.y_regions.append(y_menu + cell_h) + + def _draw_grid_vertical(self, x_grid, y_grid, cell_w, cell_h, grid_w): + """Draw the 4 × 4 cell borders, shading invalid cells in column 0.""" + grid_h = VERT_N_BITS * cell_h + # Outer border + self.ctx.display.draw_line( + x_grid, y_grid, x_grid + grid_w, y_grid, theme.frame_color + ) + self.ctx.display.draw_line( + x_grid, y_grid + grid_h, x_grid + grid_w, y_grid + grid_h, theme.frame_color + ) + self.ctx.display.draw_line( + x_grid, y_grid, x_grid, y_grid + grid_h, theme.frame_color + ) + self.ctx.display.draw_line( + x_grid + grid_w, y_grid, x_grid + grid_w, y_grid + grid_h, theme.frame_color + ) + # Internal column dividers + for col in range(1, VERT_N_DIGITS): + x = x_grid + col * cell_w + self.ctx.display.draw_line(x, y_grid, x, y_grid + grid_h, theme.frame_color) + # Internal row dividers + for row in range(1, VERT_N_BITS): + y = y_grid + row * cell_h + self.ctx.display.draw_line(x_grid, y, x_grid + grid_w, y, theme.frame_color) + # Shade cells that are structurally unreachable (first digit, rows 2-3) + for row in range(2, VERT_N_BITS): + self.ctx.display.fill_rectangle( + x_grid, y_grid + row * cell_h, cell_w, cell_h, theme.disabled_color + ) + + def _draw_punched_vertical(self, digits, x_grid, y_grid, cell_w, cell_h): + """Draw filled circles for every bit that is set in the current digits.""" + radius = max(min(cell_w, cell_h) // 3, 1) + for col, digit in enumerate(digits): + cx = x_grid + col * cell_w + cell_w // 2 + for row, bit_val in enumerate(BIT_WEIGHTS): + if digit & bit_val: + cy = y_grid + row * cell_h + cell_h // 2 + self.ctx.display.draw_circle(cx, cy, radius, theme.highlight_color) + + def _draw_menu_vertical(self, x_grid, y_menu, grid_w, cell_h): + """Draw the Esc and Go buttons below the grid.""" + label_y = y_menu + (cell_h - FONT_HEIGHT) // 2 + half_w = grid_w // 2 + esc_label = t("Esc") + go_label = t("Go") + esc_x = x_grid + (half_w - len(esc_label) * FONT_WIDTH) // 2 + go_x = x_grid + half_w + (half_w - len(go_label) * FONT_WIDTH) // 2 + self.ctx.display.draw_string(esc_x, label_y, esc_label, theme.no_esc_color) + self.ctx.display.draw_string(go_x, label_y, go_label, theme.go_color) + if kboard.has_touchscreen: + mid = x_grid + half_w + self.ctx.display.draw_line( + x_grid, y_menu, x_grid + grid_w, y_menu, theme.frame_color + ) + self.ctx.display.draw_line( + x_grid, + y_menu + cell_h, + x_grid + grid_w, + y_menu + cell_h, + theme.frame_color, + ) + self.ctx.display.draw_line( + x_grid, y_menu, x_grid, y_menu + cell_h, theme.frame_color + ) + self.ctx.display.draw_line( + mid, y_menu, mid, y_menu + cell_h, theme.frame_color + ) + self.ctx.display.draw_line( + x_grid + grid_w, + y_menu, + x_grid + grid_w, + y_menu + cell_h, + theme.frame_color, + ) + + def _draw_index_vertical( + self, index, x_grid, y_grid, cell_w, cell_h, y_menu, grid_w + ): + """Outline the currently focused cell or menu button.""" + if index >= VERT_GO_INDEX: + x = x_grid + grid_w // 2 + self.ctx.display.outline(x, y_menu, grid_w // 2, cell_h, theme.go_color) + elif index >= VERT_ESC_INDEX: + self.ctx.display.outline( + x_grid, y_menu, grid_w // 2, cell_h, theme.no_esc_color + ) + else: + col = index % VERT_N_DIGITS + row = index // VERT_N_DIGITS + self.ctx.display.outline( + x_grid + col * cell_w, + y_grid + row * cell_h, + cell_w, + cell_h, + theme.fg_color, + ) + + def _toggle_bit_vertical(self, digits, index): + """Toggle the bit addressed by *index*, enforcing first-digit constraints.""" + # The first BIP39 digit is 0-2, so bit weights 4 (row 2) and 8 (row 3) + # are invalid for column 0 and are silently ignored. + if index in VERT_INVALID_CELLS: + return digits + col = index % VERT_N_DIGITS + row = index // VERT_N_DIGITS + bit_val = BIT_WEIGHTS[row] + new_val = digits[col] ^ bit_val + if col == 0 and new_val > 2: + new_val = 0 + elif col != 0 and new_val > 9: + new_val = 0 + digits[col] = new_val + return digits + + def _index_vertical(self, index, btn): + """Advance or rewind the focused cell index, skipping invalid cells.""" + if btn in (BUTTON_PAGE, FAST_FORWARD): + index = 0 if index >= VERT_GO_INDEX else index + 1 + if index in VERT_INVALID_CELLS: + index += 1 + elif btn in (BUTTON_PAGE_PREV, FAST_BACKWARD): + index = VERT_GO_INDEX if index <= 0 else index - 1 + if index in VERT_INVALID_CELLS: + index -= 1 + return index + + def enter_1248_vertical(self): + """UI to manually enter a seed from a vertical Stackbit 1248 card.""" + x_grid, y_grid, y_start, y_menu, cell_w, cell_h, grid_w, x_label, y_preview = ( + self._layout_vertical() + ) + index = 0 + digits = [0, 0, 0, 0] + word_index = 1 + words = [] + while word_index <= 24: + self._map_keys_array_vertical(x_grid, y_grid, cell_w, cell_h, y_menu) + self.ctx.display.draw_hcentered_text("Stackbit 1248", y_start) + self.ctx.display.fill_rectangle( + x_grid, y_start + FONT_HEIGHT, grid_w, FONT_HEIGHT, theme.disabled_color + ) + self.ctx.display.draw_string( + x_grid + (grid_w - 2 * FONT_WIDTH) // 2, + y_start + FONT_HEIGHT, + "%02d" % word_index, + theme.fg_color, + theme.disabled_color, + ) + for i, label in enumerate(BIT_LABELS): + self.ctx.display.draw_string( + x_label, y_grid + i * cell_h, label, theme.fg_color + ) + self._draw_grid_vertical(x_grid, y_grid, cell_w, cell_h, grid_w) + self._draw_menu_vertical(x_grid, y_menu, grid_w, cell_h) + if self.ctx.input.buttons_active: + self._draw_index_vertical( + index, x_grid, y_grid, cell_w, cell_h, y_menu, grid_w + ) + digits_str = "".join(str(d) for d in digits) + word = self.digits_to_word(digits) + if word is not None: + preview_str = digits_str + ": " + word + color = theme.fg_color + else: + preview_str = digits_str + color = theme.error_color + self.ctx.display.draw_hcentered_text( + preview_str, y_preview, color=color, highlight_prefix=":" + ) + self._draw_punched_vertical(digits, x_grid, y_grid, cell_w, cell_h) + btn = self.ctx.input.wait_for_fastnav_button() + if btn == BUTTON_TOUCH: + btn = BUTTON_ENTER + index = self.ctx.input.touch.current_index() + if btn == BUTTON_ENTER: + if index >= VERT_GO_INDEX: + word = self.digits_to_word(digits) + if word is not None: + prompt_str = ( + str(word_index) + + ".\n\n" + + "".join(str(d) for d in digits) + + ": " + + str(word) + + "\n\n" + ) + digits = [0, 0, 0, 0] + index = 0 + self.ctx.display.clear() + if self.prompt( + prompt_str, + self.ctx.display.height() // 2, + highlight_prefix=":", + ): + words.append(word) + else: + self.ctx.display.clear() + continue + if word_index == 12: + self.ctx.display.clear() + if self.prompt(t("Done?"), self.ctx.display.height() // 2): + break + word_index += 1 + elif index >= VERT_ESC_INDEX: + self.ctx.display.clear() + if self.prompt(t("Are you sure?"), self.ctx.display.height() // 2): + break + else: + digits = self._toggle_bit_vertical(digits, index) + else: + index = self._index_vertical(index, btn) + self.ctx.display.clear() + if len(words) in (12, 24): + return words + return None diff --git a/tests/pages/test_login.py b/tests/pages/test_login.py index 2023fbab3..7782e9162 100644 --- a/tests/pages/test_login.py +++ b/tests/pages/test_login.py @@ -1396,7 +1396,8 @@ def test_load_12w_from_1248(m5stickv, mocker, mocker_printer): from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV BTN_SEQUENCE = ( - ( + [BUTTON_ENTER] # Select "Standard" from Standard/Vertical submenu + + ( [BUTTON_ENTER] # 1 press select first column num 1 + [BUTTON_PAGE_PREV] # 1 press to change to "Go" + [BUTTON_ENTER] # 1 press to select Go diff --git a/tests/pages/test_stackbit.py b/tests/pages/test_stackbit.py index 7be313213..7e847c486 100644 --- a/tests/pages/test_stackbit.py +++ b/tests/pages/test_stackbit.py @@ -72,11 +72,7 @@ def test_export_mnemonic_stackbit_standard_amigo(mocker, amigo, tdata): def test_export_mnemonic_stackbit_vertical(mocker, amigo, tdata): - """Grouped layout on Amigo: 2 words/group, 4 words/page, 6 pages for 24-word mnemonic. - - Amigo uses FONT_WIDTH=12, so 3 words/group would overflow the word name text. - The layout auto-selects 2 words/group → 4 words/page → 6 pages. - """ + """Grouped layout on Amigo: 2 words/group, 4 words/page, 6 pages for 24-word mnemonic.""" from krux.pages.home_pages.mnemonic_backup import MnemonicsView from krux.wallet import Wallet from krux.input import BUTTON_ENTER, BUTTON_PAGE @@ -113,10 +109,7 @@ def test_export_mnemonic_stackbit_vertical(mocker, amigo, tdata): def test_export_mnemonic_stackbit_vertical_compact(mocker, m5stickv, tdata): - """Dense layout on M5StickV: 6 words per page, 4 pages for 24-word mnemonic. - - 2 words side-by-side × 3 rows, no word names or BIP39 codes. - """ + """Dense layout on M5StickV: 6 words per page, 4 pages for 24-word mnemonic.""" from krux.pages.home_pages.mnemonic_backup import MnemonicsView from krux.wallet import Wallet from krux.input import BUTTON_ENTER, BUTTON_PAGE @@ -236,6 +229,212 @@ def test_esc_entering_stackbit(amigo, mocker): assert words == None +def test_enter_stackbit_vertical(m5stickv, mocker, tdata): + """Button navigation: enter 'abandon'*11+'about' on a vertical 1248 grid.""" + from krux.pages.stack_1248 import Stackbit + from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV + + ABANDON_SEQ = ( + # Move to fourth digit, toggle "1" + [BUTTON_PAGE] * 3 + + [BUTTON_ENTER] + # Go, confirm word "abandon" + + [BUTTON_PAGE_PREV] * 4 + + [BUTTON_ENTER] * 2 + ) + ABOUT_SEQ = ( + # Move to fourth digit, toggle "4" (cell 8 is skipped as invalid) + [BUTTON_PAGE] * 10 + + [BUTTON_ENTER] + # Go, confirm word "about", confirm done + + [BUTTON_PAGE_PREV] * 11 + + [BUTTON_ENTER] * 3 + ) + BTN_SEQUENCE = ABANDON_SEQ * 11 + ABOUT_SEQ + + ctx = create_ctx(mocker, BTN_SEQUENCE) + stackbit = Stackbit(ctx) + words = stackbit.enter_1248_vertical() + + assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + assert " ".join(words) == tdata.SIGNING_MNEMONIC + + +def test_enter_stackbit_vertical_touch(amigo, mocker, tdata): + """Touch navigation: enter 'abandon'*11+'about' via touch screen on vertical grid.""" + from krux.pages.stack_1248 import Stackbit, VERT_GO_INDEX + from krux.input import BUTTON_TOUCH + + YES = 1 + # 1st "abandon": touch "1" of fourth digit, touch invalid cell 8 (no-op), Go, confirm + # 10x "abandon": touch "1" of fourth digit, Go, confirm + # 1x "about": touch "4" of fourth digit, Go, confirm, done + BTN_SEQUENCE = [BUTTON_TOUCH] * 4 + [BUTTON_TOUCH] * 3 * 10 + [BUTTON_TOUCH] * 4 + TOUCH_SEQUENCE = ( + [3, 8, VERT_GO_INDEX, YES] + + [3, VERT_GO_INDEX, YES] * 10 + + [11, VERT_GO_INDEX, YES, YES] + ) + + ctx = create_ctx(mocker, BTN_SEQUENCE, touch_seq=TOUCH_SEQUENCE) + stackbit = Stackbit(ctx) + words = stackbit.enter_1248_vertical() + + assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + assert " ".join(words) == tdata.SIGNING_MNEMONIC + + +def test_esc_entering_stackbit_vertical(amigo, mocker): + """Pressing Esc from the vertical input grid returns None.""" + from krux.pages.stack_1248 import Stackbit + from krux.input import BUTTON_ENTER, BUTTON_PAGE_PREV + + BTN_SEQUENCE = ( + # Move to Esc + [BUTTON_PAGE_PREV] * 3 + + [BUTTON_ENTER] # Select "Esc" + + [BUTTON_ENTER] # Confirm + ) + ctx = create_ctx(mocker, BTN_SEQUENCE) + stackbit = Stackbit(ctx) + words = stackbit.enter_1248_vertical() + + assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + assert words is None + + +def test_enter_stackbit_vertical_reject_word(m5stickv, mocker): + """Rejecting a word confirmation returns to input without advancing word_index.""" + from krux.pages.stack_1248 import Stackbit + from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV + + BTN_SEQUENCE = ( + [BUTTON_ENTER] # toggle bit at index 0 (col 0, weight 1) + + [BUTTON_PAGE_PREV] # wrap to Go + + [BUTTON_ENTER] # press Go + + [BUTTON_PAGE] # prompt: No — reject word + # continue: digits reset, index reset to 0 + + [BUTTON_PAGE_PREV] * 3 # navigate 0 -> Go(18) -> 17 -> Esc(16) + + [BUTTON_ENTER] # press Esc + + [BUTTON_ENTER] # confirm "Are you sure?" + ) + + ctx = create_ctx(mocker, BTN_SEQUENCE) + stackbit = Stackbit(ctx) + words = stackbit.enter_1248_vertical() + + assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + assert words is None + + +def test_load_key_from_1248_standard_cancel(m5stickv, mocker): + """Selecting Standard then Esc dispatches to enter_1248 and returns MENU_CONTINUE without loading.""" + from krux.pages.mnemonic_loader import MnemonicLoader + from krux.pages.stack_1248 import Stackbit + from krux.pages import MENU_CONTINUE + from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV + + BTN_SEQUENCE = [ + BUTTON_ENTER, # submenu: select Standard (idx 0) + *([BUTTON_PAGE_PREV] * 2), # in grid: navigate to Esc + *([BUTTON_ENTER] * 2), # select Esc, confirm; callback returns MENU_CONTINUE + *([BUTTON_PAGE] * 2), # submenu: idx 0 → 1 → 2 (Back) + BUTTON_ENTER, # select Back → MENU_EXIT + ] + spy_std = mocker.spy(Stackbit, "enter_1248") + spy_vert = mocker.spy(Stackbit, "enter_1248_vertical") + ctx = create_ctx(mocker, BTN_SEQUENCE) + loader = MnemonicLoader(ctx) + spy_load = mocker.spy(loader, "_load_key_from_words") + + result = loader.load_key_from_1248() + + assert result == MENU_CONTINUE + spy_std.assert_called_once() + assert spy_std.spy_return is None + spy_vert.assert_not_called() + spy_load.assert_not_called() + assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + + +def test_load_key_from_1248_vertical_cancel(amigo, mocker): + """Selecting Vertical then Esc dispatches to enter_1248_vertical and returns MENU_CONTINUE without loading.""" + from krux.pages.mnemonic_loader import MnemonicLoader + from krux.pages.stack_1248 import Stackbit + from krux.pages import MENU_CONTINUE + from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV + + BTN_SEQUENCE = [ + BUTTON_PAGE, # submenu: move idx 0 → 1 (Vertical) + BUTTON_ENTER, # select Vertical + *([BUTTON_PAGE_PREV] * 3), # in vertical grid: navigate to Esc + BUTTON_ENTER, # select Esc + BUTTON_ENTER, # confirm Esc; callback returns MENU_CONTINUE + BUTTON_PAGE, # submenu: idx 1 → 2 (Back) + BUTTON_ENTER, # select Back → MENU_EXIT + ] + spy_std = mocker.spy(Stackbit, "enter_1248") + spy_vert = mocker.spy(Stackbit, "enter_1248_vertical") + ctx = create_ctx(mocker, BTN_SEQUENCE) + loader = MnemonicLoader(ctx) + spy_load = mocker.spy(loader, "_load_key_from_words") + + result = loader.load_key_from_1248() + + assert result == MENU_CONTINUE + spy_vert.assert_called_once() + assert spy_vert.spy_return is None + spy_std.assert_not_called() + spy_load.assert_not_called() + assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + + +def test_load_key_from_1248_vertical_loads_words(m5stickv, mocker, tdata): + """Vertical: real 12-word entry produces tdata.SIGNING_MNEMONIC, piped into _load_key_from_words.""" + from krux.pages.mnemonic_loader import MnemonicLoader + from krux.pages.stack_1248 import Stackbit + from krux.pages import MENU_CONTINUE + from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV + + ABANDON_SEQ = ( + # Move to fourth digit, toggle "1" + [BUTTON_PAGE] * 3 + + [BUTTON_ENTER] + # Go, confirm word "abandon" + + [BUTTON_PAGE_PREV] * 4 + + [BUTTON_ENTER] * 2 + ) + ABOUT_SEQ = ( + # Move to fourth digit, toggle "4" (cell 8 is skipped as invalid) + [BUTTON_PAGE] * 10 + + [BUTTON_ENTER] + # Go, confirm word "about", confirm done + + [BUTTON_PAGE_PREV] * 11 + + [BUTTON_ENTER] * 3 + ) + BTN_SEQUENCE = ( + [BUTTON_PAGE, BUTTON_ENTER] # submenu: move idx 0 → 1 (Vertical), select + + ABANDON_SEQ * 11 + + ABOUT_SEQ + + [BUTTON_PAGE, BUTTON_ENTER] # submenu: idx 1 → 2 (Back), select + ) + spy_std = mocker.spy(Stackbit, "enter_1248") + spy_vert = mocker.spy(Stackbit, "enter_1248_vertical") + ctx = create_ctx(mocker, BTN_SEQUENCE) + loader = MnemonicLoader(ctx) + mocker.patch.object(loader, "_load_key_from_words", return_value=MENU_CONTINUE) + + result = loader.load_key_from_1248() + + expected_words = tdata.SIGNING_MNEMONIC.split() + assert result == MENU_CONTINUE + spy_vert.assert_called_once() + spy_std.assert_not_called() + assert spy_vert.spy_return == expected_words + loader._load_key_from_words.assert_called_once_with(expected_words) + assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + + def test_entering_stackbit_buttons_turbo(mocker, m5stickv): from krux.pages.stack_1248 import Stackbit from krux.input import PRESSED, FAST_FORWARD, FAST_BACKWARD, Input @@ -264,3 +463,104 @@ def test_entering_stackbit_buttons_turbo(mocker, m5stickv): stackbit.enter_1248() stackbit.index.assert_called_with(0, FAST_BACKWARD) + + +def test_enter_stackbit_vertical_navigation_skips_invalid_cells(m5stickv, mocker): + """Forward and backward sweeps over the vertical grid skip cells 8 and 12.""" + from krux.pages.stack_1248 import Stackbit, VERT_INVALID_CELLS + from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV + + BTN_SEQUENCE = ( + [BUTTON_PAGE] * 11 # 0 → 9 (skip 8) → 13 (skip 12) + + [BUTTON_PAGE_PREV] * 4 # 13 → 11 (skip 12) → 7 (skip 8) + + [BUTTON_PAGE] * 7 # 7 → 16 (skip 8, 12); lands on Esc + + [BUTTON_ENTER] * 2 # select Esc, confirm + ) + spy_index = mocker.spy(Stackbit, "_index_vertical") + ctx = create_ctx(mocker, BTN_SEQUENCE) + stackbit = Stackbit(ctx) + words = stackbit.enter_1248_vertical() + + assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + assert words is None + assert spy_index.call_count == len(BTN_SEQUENCE) - 2 # ENTERs don't navigate + for return_value in spy_index.spy_return_list: + assert return_value not in VERT_INVALID_CELLS, ( + "navigation landed on invalid cell %d" % return_value + ) + forward_returns = [ + spy_index.spy_return_list[i] + for i, call in enumerate(spy_index.call_args_list) + if call.args[-1] == BUTTON_PAGE + ] + backward_returns = [ + spy_index.spy_return_list[i] + for i, call in enumerate(spy_index.call_args_list) + if call.args[-1] == BUTTON_PAGE_PREV + ] + assert 9 in forward_returns and 13 in forward_returns + assert 11 in backward_returns and 7 in backward_returns + + +def test_enter_stackbit_vertical_clamps_digit_on_overflow(m5stickv, mocker): + """Toggling a bit that would push col-0 above 2 (or any other col above 9) clamps the digit to 0.""" + from krux.pages.stack_1248 import Stackbit + from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV + + BTN_SEQUENCE = ( + # col 0 overflow path + [BUTTON_ENTER] # cell 0: digits=[1,0,0,0] + + [BUTTON_PAGE] * 4 # 0 → 4 (col 0, row 1, weight 2) + + [BUTTON_ENTER] # cell 4: 1^2=3 > 2 → clamp digits[0] to 0 + # col 1 overflow path + + [BUTTON_PAGE] * 7 # 4 → 13 (col 1, row 3, weight 8); skips 8 and 12 + + [BUTTON_ENTER] # cell 13: digits=[0,8,0,0] + + [BUTTON_PAGE_PREV] * 6 # 13 → 5 (col 1, row 1, weight 2); skips 12 and 8 + + [BUTTON_ENTER] # cell 5: 8^2=10 > 9 → clamp digits[1] to 0 + # exit + + [BUTTON_PAGE] * 9 # 5 → 16 (Esc); skips 8 and 12 + + [BUTTON_ENTER] * 2 # select Esc, confirm + ) + snapshots = [] + real_toggle = Stackbit._toggle_bit_vertical + + def snap_toggle(self, digits, index): + result = real_toggle(self, digits, index) + snapshots.append((index, list(result))) + return result + + mocker.patch.object(Stackbit, "_toggle_bit_vertical", new=snap_toggle) + ctx = create_ctx(mocker, BTN_SEQUENCE) + stackbit = Stackbit(ctx) + words = stackbit.enter_1248_vertical() + + assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + assert words is None + assert snapshots == [ + (0, [1, 0, 0, 0]), # cell 0: set bit-1 in col 0 + (4, [0, 0, 0, 0]), # cell 4: col-0 overflow (3 > 2) → clamp to 0 + (13, [0, 8, 0, 0]), # cell 13: set bit-8 in col 1 + (5, [0, 0, 0, 0]), # cell 5: col-1 overflow (10 > 9) → clamp to 0 + ] + + +def test_enter_stackbit_vertical_index_wraps_at_grid_boundaries(m5stickv, mocker): + """PAGE_PREV at index 0 wraps to VERT_GO_INDEX; PAGE at VERT_GO_INDEX wraps to 0.""" + from krux.pages.stack_1248 import Stackbit, VERT_GO_INDEX + from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV + + BTN_SEQUENCE = ( + [BUTTON_PAGE_PREV] # 0 → VERT_GO_INDEX (backward wrap) + + [BUTTON_PAGE] # VERT_GO_INDEX → 0 (forward wrap) + + [BUTTON_PAGE_PREV] * 3 # 0 → 18 → 17 → 16 (Esc) + + [BUTTON_ENTER] * 2 # select Esc, confirm + ) + spy_index = mocker.spy(Stackbit, "_index_vertical") + ctx = create_ctx(mocker, BTN_SEQUENCE) + stackbit = Stackbit(ctx) + words = stackbit.enter_1248_vertical() + + assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE) + assert words is None + assert spy_index.spy_return_list[0] == VERT_GO_INDEX # backward wrap from 0 + assert spy_index.spy_return_list[1] == 0 # forward wrap from VERT_GO_INDEX diff --git a/tests/test_display.py b/tests/test_display.py index a814c524c..f163af308 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -671,6 +671,35 @@ def test_fill_rectangle_on_inverted_display(mocker, amigo): ) +def test_draw_circle(mocker, m5stickv): + mocker.patch("krux.display.lcd", new=mocker.MagicMock()) + import krux + from krux.display import Display + + d = Display() + + d.draw_circle(50, 50, 10, krux.display.lcd.WHITE) + + krux.display.lcd.draw_circle.assert_called_with( + 50, 50, 10, 0, krux.display.lcd.WHITE + ) + + +def test_draw_circle_on_inverted_display(mocker, amigo): + mocker.patch("krux.display.lcd", new=mocker.MagicMock()) + import krux + from krux.display import Display + + d = Display() + mocker.patch.object(d, "width", new=lambda: 480) + + d.draw_circle(50, 50, 10, krux.display.lcd.WHITE) + + krux.display.lcd.draw_circle.assert_called_with( + 480 - 50 - 1, 50, 10, 0, krux.display.lcd.WHITE + ) + + def test_draw_string(mocker, m5stickv): mocker.patch("krux.display.lcd", new=mocker.MagicMock()) import krux