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