Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/getting-started/usage/loading-a-mnemonic.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<div style="clear: both"></div>
<img src="../../../img/maixpy_m5stickv/load-mnemonic-via-stackbit-vertical-filled-250.png" align="right" class="m5stickv">
<img src="../../../img/maixpy_amigo/load-mnemonic-via-stackbit-vertical-filled-300.png" align="right" class="amigo">

Vertical layout transposes the grid, with rows = weights (1,2,4,8) and columns = digits.

<div style="clear: both"></div>

### From Storage
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions simulator/sequences/load-mnemonic-via-stackbit.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions src/krux/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 15 additions & 1 deletion src/krux/pages/mnemonic_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
bitcoisas marked this conversation as resolved.

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)
Expand Down
285 changes: 284 additions & 1 deletion src/krux/pages/stack_1248.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion tests/pages/test_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
+ (
Comment thread
bitcoisas marked this conversation as resolved.
[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
Expand Down
Loading
Loading