diff --git a/CHANGELOG.md b/CHANGELOG.md index 241f25d7c..c06c5e3ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # 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. + ### 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/navigating-the-main-menu.en.md b/docs/getting-started/usage/navigating-the-main-menu.en.md index 3857fbcd9..764cfd6a8 100644 --- a/docs/getting-started/usage/navigating-the-main-menu.en.md +++ b/docs/getting-started/usage/navigating-the-main-menu.en.md @@ -96,11 +96,15 @@ Display the BIP39 mnemonic word numbers (1-2048) in decimal, hex, or octal forma - This metal backup format represents the BIP39 mnemonic word's numbers (1-2048). Each of the four digits is converted to a sum of 1, 2, 4 or 8. This option does not print even if a printer driver is set. +
+ + +Vertical layout transposes the grid, with rows = weights (1,2,4,8) and columns = digits.
+ - **Tinyseed** diff --git a/docs/img/maixpy_amigo/backup-stackbit-vertical-300.png b/docs/img/maixpy_amigo/backup-stackbit-vertical-300.png new file mode 100644 index 000000000..6a69196ca Binary files /dev/null and b/docs/img/maixpy_amigo/backup-stackbit-vertical-300.png differ diff --git a/docs/img/maixpy_m5stickv/backup-stackbit-vertical-250.png b/docs/img/maixpy_m5stickv/backup-stackbit-vertical-250.png new file mode 100644 index 000000000..249b2a57d Binary files /dev/null and b/docs/img/maixpy_m5stickv/backup-stackbit-vertical-250.png differ diff --git a/i18n/translations/de-DE.json b/i18n/translations/de-DE.json index f2194616a..c3783059a 100644 --- a/i18n/translations/de-DE.json +++ b/i18n/translations/de-DE.json @@ -284,6 +284,7 @@ "Some nodes are not hardened:": "Einige Knoten sind nicht gehärtet:", "Spend (%d):": "Ausgabe (%d):", "Spend:": "Ausgaben:", + "Standard": "Standard", "Standard mode": "Standardmodus", "Static": "Statisch", "Stats for Nerds": "Statistiken für Nerds", @@ -331,6 +332,7 @@ "Value %s out of range: [%s, %s]": "Wert %S außerhalb des Bereichs: [ %s, %s]", "Verifying…": "Überprüfung…", "Version": "Version", + "Vertical": "Vertikal", "Via Camera": "Via Kamera", "Via D20": "Via D20", "Via D6": "Via D6", diff --git a/i18n/translations/es-MX.json b/i18n/translations/es-MX.json index 2784413d1..0e3960ba2 100644 --- a/i18n/translations/es-MX.json +++ b/i18n/translations/es-MX.json @@ -284,6 +284,7 @@ "Some nodes are not hardened:": "Algunos nodos no están endurecidos:", "Spend (%d):": "Gastos (%d):", "Spend:": "Gasto:", + "Standard": "Estándar", "Standard mode": "Modo estándar", "Static": "Estático", "Stats for Nerds": "Estadísticas para Entendidos", @@ -331,6 +332,7 @@ "Value %s out of range: [%s, %s]": "Valor %s fuera del rango: [ %s, %s]", "Verifying…": "Verificando…", "Version": "Versión", + "Vertical": "Vertical", "Via Camera": "Desde Cámara", "Via D20": "Vía D20", "Via D6": "Vía D6", diff --git a/i18n/translations/fr-FR.json b/i18n/translations/fr-FR.json index d761a902f..78fc38eee 100644 --- a/i18n/translations/fr-FR.json +++ b/i18n/translations/fr-FR.json @@ -284,6 +284,7 @@ "Some nodes are not hardened:": "Certains nœuds ne sont pas durcis :", "Spend (%d):": "Dépense (%d) :", "Spend:": "Dépense :", + "Standard": "Standard", "Standard mode": "Mode standard", "Static": "Statique", "Stats for Nerds": "Statistiques pour les geeks", @@ -331,6 +332,7 @@ "Value %s out of range: [%s, %s]": "Valeur %s hors de portée: [%s, %s]", "Verifying…": "Vérification…", "Version": "Version", + "Vertical": "Vertical", "Via Camera": "Par caméra", "Via D20": "Via D20", "Via D6": "Via D6", diff --git a/i18n/translations/ja-JP.json b/i18n/translations/ja-JP.json index facbd1edc..4806accdf 100644 --- a/i18n/translations/ja-JP.json +++ b/i18n/translations/ja-JP.json @@ -284,6 +284,7 @@ "Some nodes are not hardened:": "一部のノードは硬化されていません:", "Spend (%d):": "支出(%d):", "Spend:": "支出:", + "Standard": "標準", "Standard mode": "標準モード", "Static": "静止画", "Stats for Nerds": "オタクのための統計", @@ -331,6 +332,7 @@ "Value %s out of range: [%s, %s]": "値%sが範囲外です: [ %s, %s]", "Verifying…": "認証中…", "Version": "バージョン", + "Vertical": "縦向き", "Via Camera": "カメラ経由", "Via D20": "D20経由", "Via D6": "D6経由", diff --git a/i18n/translations/ko-KR.json b/i18n/translations/ko-KR.json index 6b197e5b3..6c00a9b97 100644 --- a/i18n/translations/ko-KR.json +++ b/i18n/translations/ko-KR.json @@ -284,6 +284,7 @@ "Some nodes are not hardened:": "일부 노드가 경화되지 않습니다:", "Spend (%d):": "Spend (%d):", "Spend:": "지출:", + "Standard": "표준", "Standard mode": "표준 모드", "Static": "Static", "Stats for Nerds": "전문가를 위한 통계", @@ -331,6 +332,7 @@ "Value %s out of range: [%s, %s]": "%s는 [%s, %s] 범위를 벗어났습니다", "Verifying…": "확인…", "Version": "버전", + "Vertical": "세로", "Via Camera": "카메라", "Via D20": "20면체 주사위", "Via D6": "일반 주사위", diff --git a/i18n/translations/nl-NL.json b/i18n/translations/nl-NL.json index 7b9517beb..00ddb338a 100644 --- a/i18n/translations/nl-NL.json +++ b/i18n/translations/nl-NL.json @@ -284,6 +284,7 @@ "Some nodes are not hardened:": "Sommige knooppunten zijn niet gehard:", "Spend (%d):": "Uitgaven (%d):", "Spend:": "Uitgaven:", + "Standard": "Standaard", "Standard mode": "Standaardmodus", "Static": "Statisch", "Stats for Nerds": "Statistieken voor nerds", @@ -331,6 +332,7 @@ "Value %s out of range: [%s, %s]": "Waarde %s is buiten bereik: [%s, %s]", "Verifying…": "Controleren…", "Version": "Versie", + "Vertical": "Verticaal", "Via Camera": "Via camera", "Via D20": "Via D20", "Via D6": "Via D6", diff --git a/i18n/translations/pt-BR.json b/i18n/translations/pt-BR.json index 38c4f3755..ce6a25e94 100644 --- a/i18n/translations/pt-BR.json +++ b/i18n/translations/pt-BR.json @@ -284,6 +284,7 @@ "Some nodes are not hardened:": "Alguns nós não são hardened:", "Spend (%d):": "Gastos (%d):", "Spend:": "Gasto:", + "Standard": "Padrão", "Standard mode": "Modo padrão", "Static": "Estático", "Stats for Nerds": "Estatísticas para nerds", @@ -331,6 +332,7 @@ "Value %s out of range: [%s, %s]": "Valor %s fora do intervalo: [%s, %s]", "Verifying…": "Checando…", "Version": "Versão", + "Vertical": "Vertical", "Via Camera": "Pela Câmera", "Via D20": "Via D20", "Via D6": "Via D6", diff --git a/i18n/translations/ru-RU.json b/i18n/translations/ru-RU.json index cce43232f..61a8901af 100644 --- a/i18n/translations/ru-RU.json +++ b/i18n/translations/ru-RU.json @@ -284,6 +284,7 @@ "Some nodes are not hardened:": "Некоторые узлы не укреплены:", "Spend (%d):": "Расход (%d):", "Spend:": "Расход:", + "Standard": "Стандартный", "Standard mode": "Стандартный режим", "Static": "Static / Статическое оборудование", "Stats for Nerds": "Статистика для Гиков", @@ -331,6 +332,7 @@ "Value %s out of range: [%s, %s]": "Значение %s вне диапозона: [%s, %s]", "Verifying…": "Верификация…", "Version": "Версия", + "Vertical": "Вертикальный", "Via Camera": "С Помощью Камеры", "Via D20": "С Помощью D20", "Via D6": "С Помощью D6", diff --git a/i18n/translations/tr-TR.json b/i18n/translations/tr-TR.json index c573f542b..3c9f98b7f 100644 --- a/i18n/translations/tr-TR.json +++ b/i18n/translations/tr-TR.json @@ -284,6 +284,7 @@ "Some nodes are not hardened:": "Bazı düğümler sertleştirilmemiş:", "Spend (%d):": "Harcama (%d):", "Spend:": "Harcama:", + "Standard": "Standart", "Standard mode": "Standart Mod", "Static": "Statik", "Stats for Nerds": "İnekler İçin İstatistikler", @@ -331,6 +332,7 @@ "Value %s out of range: [%s, %s]": "%s değeri aralık dışında: [%s, %s]", "Verifying…": "Doğrulanıyor…", "Version": "Sürüm", + "Vertical": "Dikey", "Via Camera": "Kamera Aracılığıyla", "Via D20": "D20 Aracılığıyla", "Via D6": "D6 Aracılığıyla", diff --git a/i18n/translations/vi-VN.json b/i18n/translations/vi-VN.json index 9648090a3..31b7f7cd5 100644 --- a/i18n/translations/vi-VN.json +++ b/i18n/translations/vi-VN.json @@ -284,6 +284,7 @@ "Some nodes are not hardened:": "Một số nút không được làm cứng:", "Spend (%d):": "Chi tiêu (%d):", "Spend:": "Chi tiêu:", + "Standard": "Tiêu chuẩn", "Standard mode": "Chế độ Tiêu chuẩn", "Static": "Tĩnh", "Stats for Nerds": "Số liệu thống kê cho Mọt sách", @@ -331,6 +332,7 @@ "Value %s out of range: [%s, %s]": "Giá trị %s ngoài phạm vi: [ %s, %s]", "Verifying…": "Xác minh…", "Version": "Phiên Bản", + "Vertical": "Dọc", "Via Camera": "Qua máy ảnh", "Via D20": "Qua xúc xắc 20 mặt", "Via D6": "Qua xúc xắc 6 mặt", diff --git a/i18n/translations/zh-CN.json b/i18n/translations/zh-CN.json index 7cad610df..3a2f0d23e 100644 --- a/i18n/translations/zh-CN.json +++ b/i18n/translations/zh-CN.json @@ -284,6 +284,7 @@ "Some nodes are not hardened:": "有些节点未硬化:", "Spend (%d):": "花费 (%d):", "Spend:": "花费", + "Standard": "标准", "Standard mode": "标准模式", "Static": "Static 静态?", "Stats for Nerds": "极客统计数据", @@ -330,6 +331,7 @@ "User's Data": "用户数据", "Value %s out of range: [%s, %s]": "值 %s 超出范围:[ %s,%s ]", "Verifying…": "验证中…", + "Vertical": "垂直", "Version": "版本", "Via Camera": "通过摄像头", "Via D20": "通过 D20", diff --git a/simulator/sequences/home-options.txt b/simulator/sequences/home-options.txt index 6761ee1b3..4c8f9ecfb 100644 --- a/simulator/sequences/home-options.txt +++ b/simulator/sequences/home-options.txt @@ -52,9 +52,23 @@ press BUTTON_A press BUTTON_B press BUTTON_A +screenshot backup-stackbit-menu.png + +press BUTTON_B +press BUTTON_A + +screenshot backup-stackbit-vertical.png + +x2 press BUTTON_A +press_amigo_only BUTTON_A +x2 press BUTTON_B +press BUTTON_A + screenshot backup-stackbit.png x2 press BUTTON_A +x2 press BUTTON_B +press BUTTON_A press BUTTON_B press BUTTON_A diff --git a/src/krux/pages/home_pages/mnemonic_backup.py b/src/krux/pages/home_pages/mnemonic_backup.py index a0581ca76..88b1f50d0 100644 --- a/src/krux/pages/home_pages/mnemonic_backup.py +++ b/src/krux/pages/home_pages/mnemonic_backup.py @@ -178,7 +178,19 @@ def display_seed_qr(self, binary=False): return seed_qr_view.display_qr() def stackbit(self): - """Displays which numbers 1248 user should punch on 1248 steel card""" + """Displays layout selection submenu for Stackbit 1248 backup""" + submenu = Menu( + self.ctx, + [ + (t("Standard"), self._stackbit_standard), + (t("Vertical"), self._stackbit_vertical), + ], + ) + submenu.run_loop() + return MENU_CONTINUE + + def _stackbit_standard(self): + """Displays Stackbit 1248 in standard (horizontal) format — 6 words per page""" from ..stack_1248 import Stackbit stackbit = Stackbit(self.ctx) @@ -198,6 +210,67 @@ def stackbit(self): self.ctx.display.clear() return MENU_CONTINUE + def _stackbit_vertical(self): + """Dispatches to the grouped or minimal layout based on the current device""" + if kboard.has_minimal_display: + return self._stackbit_vertical_compact() + return self._stackbit_vertical_default() + + def _stackbit_vertical_default(self): + """Draws vertical Stackbit 1248 layout with 2 words per group, 4 words per page""" + from ..stack_1248 import Stackbit + + stackbit = Stackbit(self.ctx) + words = self.ctx.wallet.key.mnemonic.split(" ") + total_words = len(words) + + words_per_group = 2 + + groups_per_page = 2 + block_h = 7 * FONT_HEIGHT + 2 + group_gap = max(2, FONT_HEIGHT // 4) + + word_index = 1 + while word_index <= total_words: + self.ctx.display.draw_hcentered_text("Stackbit 1248") + y_offset = 2 * FONT_HEIGHT + for _ in range(groups_per_page): + if word_index > total_words: + break + group = [] + for _ in range(words_per_group): + if word_index > total_words: + break + group.append((word_index, words[word_index - 1])) + word_index += 1 + stackbit.export_1248_vertical_grouped(y_offset, group) + y_offset += block_h + group_gap + self.ctx.input.wait_for_button() + self.ctx.display.clear() + return MENU_CONTINUE + + def _stackbit_vertical_compact(self): + """Draws compact Stackbit 1248 layout for M5StickV, 6 words per page""" + from ..stack_1248 import Stackbit + + stackbit = Stackbit(self.ctx) + words = self.ctx.wallet.key.mnemonic.split(" ") + total_words = len(words) + words_per_page = 6 + word_index = 0 + + while word_index < total_words: + self.ctx.display.draw_hcentered_text("Stackbit 1248") + page_words = [] + for _ in range(words_per_page): + if word_index < total_words: + page_words.append((word_index + 1, words[word_index])) + word_index += 1 + stackbit.export_1248_vertical_compact(page_words, 2 * FONT_HEIGHT) + self.ctx.input.wait_for_button() + self.ctx.display.clear() + return MENU_CONTINUE + def tiny_seed(self): """Displays the seed in Tinyseed format""" from ..tiny_seed import TinySeed diff --git a/src/krux/pages/stack_1248.py b/src/krux/pages/stack_1248.py index 0eaa700f2..a86b17b63 100644 --- a/src/krux/pages/stack_1248.py +++ b/src/krux/pages/stack_1248.py @@ -38,6 +38,8 @@ STACKBIT_GO_INDEX = 38 STACKBIT_ESC_INDEX = 35 STACKBIT_MAX_INDEX = 13 +BIT_WEIGHTS = (1, 2, 4, 8) +BIT_LABELS = ("1", "2", "4", "8") class Stackbit(Page): @@ -342,6 +344,248 @@ def _draw_menu(self): ) x_offset += 3 * self.x_pad + def export_1248_vertical_compact( + self, words_list, y_start + ): # pylint: disable=too-many-locals + """Draws compact Stackbit 1248 grids for minimal displays, 2 words per row""" + n_word_cols = 2 + n_cols_per_word = 4 + word_col_gap = 4 + row_gap = 3 + header_h = FONT_HEIGHT + + label_w = FONT_WIDTH + x_start = MINIMAL_PADDING + right_pad = MINIMAL_PADDING + + available_w = ( + self.ctx.display.width() - x_start - label_w - word_col_gap - right_pad + ) + cell_w = available_w // (n_word_cols * n_cols_per_word) + + n_rows = (len(words_list) + n_word_cols - 1) // n_word_cols + available_h = self.ctx.display.height() - y_start + cell_h = (available_h - n_rows * header_h - (n_rows - 1) * row_gap) // ( + n_rows * 4 + ) + cell_h = min(cell_h, FONT_HEIGHT) + + grid_w = n_cols_per_word * cell_w + row_total_h = header_h + 4 * cell_h + + dot_size = max(min(cell_w, cell_h) - 6, 1) + radius = dot_size // 2 + + x_label = x_start + x_grid_left = x_label + label_w + x_grid_right = x_grid_left + grid_w + word_col_gap + + for row_idx in range(n_rows): + word_pair = words_list[row_idx * n_word_cols : (row_idx + 1) * n_word_cols] + y_row = y_start + row_idx * (row_total_h + row_gap) + y_grid = y_row + header_h # grid starts below the header + grid_h = 4 * cell_h + + # Word-number headers + for col_idx, (word_idx, _) in enumerate(word_pair): + x_grid = x_grid_left if col_idx == 0 else x_grid_right + self.ctx.display.fill_rectangle( + x_grid, y_row, grid_w, header_h, theme.disabled_color + ) + x_num = x_grid + (grid_w - 2 * FONT_WIDTH) // 2 + self.ctx.display.draw_string( + x_num, + y_row, + "%02d" % word_idx, + theme.fg_color, + theme.disabled_color, + ) + + # Row labels + for bit_row, label in enumerate(BIT_LABELS): + self.ctx.display.draw_string( + x_label, + y_grid + bit_row * cell_h, + label, + theme.fg_color, + ) + + for col_idx, (_, word) in enumerate(word_pair): + x_grid = x_grid_left if col_idx == 0 else x_grid_right + + # Outer grid 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 vertical column dividers + for c in range(1, n_cols_per_word): + x_line = x_grid + c * cell_w + self.ctx.display.draw_line( + x_line, y_grid, x_line, y_grid + grid_h, theme.frame_color + ) + + # Internal horizontal row dividers + for r in range(1, 4): + y_line = y_grid + r * cell_h + self.ctx.display.draw_line( + x_grid, y_line, x_grid + grid_w, y_line, theme.frame_color + ) + + # Punched marks + digits, _ = self._word_to_digits(word) + for col, d in enumerate(digits): + x_col = x_grid + col * cell_w + for bit_row, bit_val in enumerate(BIT_WEIGHTS): + if d & bit_val: + x_dot = x_col + (cell_w - dot_size) // 2 + y_dot = y_grid + bit_row * cell_h + (cell_h - dot_size) // 2 + self.ctx.display.fill_rectangle( + x_dot, + y_dot, + dot_size, + dot_size, + theme.highlight_color, + radius, + ) + + def export_1248_vertical_grouped( + self, y_offset, words_group + ): # pylint: disable=too-many-locals + """Draws grouped Stackbit 1248 grids with individual bordered sections per word""" + if kboard.is_m5stickv: + self.x_offset = MINIMAL_PADDING + else: + self.x_offset = DEFAULT_PADDING + + n_words = len(words_group) + n_cols_per_word = 4 + n_gaps = n_words - 1 + word_gap = 4 + + label_w = FONT_WIDTH + 2 + available = self.ctx.display.width() - self.x_offset - DEFAULT_PADDING - label_w + cell_w = (available - n_gaps * word_gap) // (n_words * n_cols_per_word) + cell_h = FONT_HEIGHT + header_h = FONT_HEIGHT + + word_block_w = n_cols_per_word * cell_w + word_stride = word_block_w + word_gap + + x_label = self.x_offset + x_grid0 = x_label + label_w + grid_h = 4 * cell_h + y_grid = y_offset + header_h + + # Row labels + for i, label in enumerate(BIT_LABELS): + self.ctx.display.draw_string( + x_label, y_grid + i * cell_h, label, theme.fg_color + ) + + # Per-word header and grid + for i, (word_idx, _) in enumerate(words_group): + x_sec = x_grid0 + i * word_stride + + # Header background with centred word number + self.ctx.display.fill_rectangle( + x_sec, y_offset, word_block_w, header_h, theme.disabled_color + ) + x_num = x_sec + (word_block_w - 2 * FONT_WIDTH) // 2 + self.ctx.display.draw_string( + x_num, y_offset, "%02d" % word_idx, theme.fg_color, theme.disabled_color + ) + + # Outer border of this word's grid + self.ctx.display.draw_line( + x_sec, y_grid, x_sec + word_block_w, y_grid, theme.frame_color + ) + self.ctx.display.draw_line( + x_sec, + y_grid + grid_h, + x_sec + word_block_w, + y_grid + grid_h, + theme.frame_color, + ) + self.ctx.display.draw_line( + x_sec, y_grid, x_sec, y_grid + grid_h, theme.frame_color + ) + self.ctx.display.draw_line( + x_sec + word_block_w, + y_grid, + x_sec + word_block_w, + y_grid + grid_h, + theme.frame_color, + ) + + # Vertical column dividers + for col in range(1, n_cols_per_word): + x_line = x_sec + col * cell_w + self.ctx.display.draw_line( + x_line, y_grid, x_line, y_grid + grid_h, theme.frame_color + ) + + # Horizontal row dividers + if i == 0: + for row in range(1, 4): + y_line = y_grid + row * cell_h + for j in range(n_words): + xs = x_grid0 + j * word_stride + self.ctx.display.draw_line( + xs, y_line, xs + word_block_w, y_line, theme.frame_color + ) + + # Punched marks + dot_size = max(min(cell_w, cell_h) - 6, 1) + radius = dot_size // 2 + + for w_i, (_, word) in enumerate(words_group): + x_sec = x_grid0 + w_i * word_stride + digits, _ = self._word_to_digits(word) + for col, d in enumerate(digits): + x_col = x_sec + col * cell_w + for row_idx, bit_val in enumerate(BIT_WEIGHTS): + if d & bit_val: + x_dot = x_col + (cell_w - dot_size) // 2 + y_dot = y_grid + row_idx * cell_h + (cell_h - dot_size) // 2 + self.ctx.display.fill_rectangle( + x_dot, + y_dot, + dot_size, + dot_size, + theme.highlight_color, + radius, + ) + + # Code and word name below each section + y_text = y_grid + grid_h + 2 + for i, (_, word) in enumerate(words_group): + x_sec = x_grid0 + i * word_stride + _, digits_str = self._word_to_digits(word) + self.ctx.display.draw_string( + x_sec, y_text, digits_str, theme.highlight_color + ) + self.ctx.display.draw_string( + x_sec, y_text + FONT_HEIGHT, word, theme.disabled_color + ) + def digits_to_word(self, digits): """Returns seed word respective to digits BIP39 dictionaty position""" word_number = int("".join(str(num) for num in digits)) diff --git a/tests/pages/test_stackbit.py b/tests/pages/test_stackbit.py index e695db784..7be313213 100644 --- a/tests/pages/test_stackbit.py +++ b/tests/pages/test_stackbit.py @@ -1,7 +1,8 @@ from .home_pages.test_home import tdata, create_ctx -def test_export_mnemonic_stackbit(mocker, m5stickv, tdata): +def test_export_mnemonic_stackbit_standard(mocker, m5stickv, tdata): + """Standard layout: 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 @@ -10,32 +11,34 @@ def test_export_mnemonic_stackbit(mocker, m5stickv, tdata): Wallet(tdata.SINGLESIG_24_WORD_KEY), None, [ - BUTTON_PAGE, - BUTTON_PAGE, - BUTTON_ENTER, # Other - BUTTON_PAGE, - BUTTON_PAGE, - BUTTON_ENTER, # Open Stackbit - BUTTON_ENTER, # PG2 - BUTTON_ENTER, # PG3 - BUTTON_ENTER, # PG4 - BUTTON_ENTER, # Leave - BUTTON_PAGE, # Go to "Back" - BUTTON_PAGE, - BUTTON_ENTER, # click on back to return Mnemonic Backup - BUTTON_PAGE, - BUTTON_ENTER, # click on back to return to home init screen + *([BUTTON_PAGE] * 2), # Go to "Other Formats" + BUTTON_ENTER, # Select "Other Formats" + *([BUTTON_PAGE] * 2), # Go to "Open Stackbit" + *( + [BUTTON_ENTER] * 6 + ), # Select "Open Stackbit", "Standard", PG2, PG3, PG4, leave + *([BUTTON_PAGE] * 2), # Go to "Back" in Stackbit submenu + BUTTON_ENTER, # Select "Back" from Stackbit submenu + *([BUTTON_PAGE] * 2), # Go to "Back" in Other Formats + BUTTON_ENTER, # Select "Back" from Other Formats + BUTTON_PAGE, # Go to "Back" in mnemonic menu + BUTTON_ENTER, # Select "Back" ], ] ctx = create_ctx(mocker, case[2], case[0], case[1]) mnemonics = MnemonicsView(ctx) mocker.spy(mnemonics, "stackbit") + mocker.spy(mnemonics, "_stackbit_standard") + mocker.spy(mnemonics, "_stackbit_vertical_compact") mnemonics.mnemonic() mnemonics.stackbit.assert_called_once() + mnemonics._stackbit_standard.assert_called_once() + mnemonics._stackbit_vertical_compact.assert_not_called() assert ctx.input.wait_for_button.call_count == len(case[2]) -def test_export_mnemonic_stackbit_amigo(mocker, amigo, tdata): +def test_export_mnemonic_stackbit_standard_amigo(mocker, amigo, tdata): + """Standard layout on Amigo: 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 @@ -44,28 +47,108 @@ def test_export_mnemonic_stackbit_amigo(mocker, amigo, tdata): Wallet(tdata.SINGLESIG_24_WORD_KEY), None, [ - BUTTON_PAGE, - BUTTON_PAGE, - BUTTON_ENTER, # Other - BUTTON_PAGE, - BUTTON_PAGE, - BUTTON_ENTER, # Open Stackbit - BUTTON_ENTER, # PG2 - BUTTON_ENTER, # PG3 - BUTTON_ENTER, # PG4 - BUTTON_ENTER, # Leave - BUTTON_PAGE, # Go to "Back" - BUTTON_PAGE, - BUTTON_ENTER, # click on back to return Mnemonic Backup - BUTTON_PAGE, - BUTTON_ENTER, # click on back to return to home init screen + *([BUTTON_PAGE] * 2), # Go to "Other Formats" + BUTTON_ENTER, # Select "Other Formats" + *([BUTTON_PAGE] * 2), # Go to "Open Stackbit" + *( + [BUTTON_ENTER] * 6 + ), # Select "Open Stackbit", "Standard", PG2, PG3, PG4, leave + *([BUTTON_PAGE] * 2), # Go to "Back" in Stackbit submenu + BUTTON_ENTER, # Select "Back" from Stackbit submenu + *([BUTTON_PAGE] * 2), # Go to "Back" in Other Formats + BUTTON_ENTER, # Select "Back" from Other Formats + BUTTON_PAGE, # Go to "Back" in mnemonic menu + BUTTON_ENTER, # Select "Back" ], ] ctx = create_ctx(mocker, case[2], case[0], case[1]) mnemonics = MnemonicsView(ctx) mocker.spy(mnemonics, "stackbit") + mocker.spy(mnemonics, "_stackbit_standard") mnemonics.mnemonic() mnemonics.stackbit.assert_called_once() + mnemonics._stackbit_standard.assert_called_once() + assert ctx.input.wait_for_button.call_count == len(case[2]) + + +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. + """ + from krux.pages.home_pages.mnemonic_backup import MnemonicsView + from krux.wallet import Wallet + from krux.input import BUTTON_ENTER, BUTTON_PAGE + + case = [ + Wallet(tdata.SINGLESIG_24_WORD_KEY), + None, + [ + *([BUTTON_PAGE] * 2), # Go to "Other Formats" + BUTTON_ENTER, # Select "Other Formats" + *([BUTTON_PAGE] * 2), # Go to "Open Stackbit" + BUTTON_ENTER, # Select "Open Stackbit" + BUTTON_PAGE, # Go to "Vertical" + BUTTON_ENTER, # Select "Vertical" + *([BUTTON_ENTER] * 6), # Advance 6 pages + BUTTON_PAGE, # Go to "Back" in Stackbit submenu + BUTTON_ENTER, # Select "Back" from Stackbit submenu + *([BUTTON_PAGE] * 2), # Go to "Back" in Other Formats + BUTTON_ENTER, # Select "Back" from Other Formats + BUTTON_PAGE, # Go to "Back" in mnemonic menu + BUTTON_ENTER, # Select "Back" + ], + ] + ctx = create_ctx(mocker, case[2], case[0], case[1]) + mnemonics = MnemonicsView(ctx) + mocker.spy(mnemonics, "stackbit") + mocker.spy(mnemonics, "_stackbit_vertical") + mocker.spy(mnemonics, "_stackbit_vertical_default") + mnemonics.mnemonic() + mnemonics.stackbit.assert_called_once() + mnemonics._stackbit_vertical.assert_called_once() + mnemonics._stackbit_vertical_default.assert_called_once() + assert ctx.input.wait_for_button.call_count == len(case[2]) + + +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. + """ + from krux.pages.home_pages.mnemonic_backup import MnemonicsView + from krux.wallet import Wallet + from krux.input import BUTTON_ENTER, BUTTON_PAGE + + case = [ + Wallet(tdata.SINGLESIG_24_WORD_KEY), + None, + [ + *([BUTTON_PAGE] * 2), # Go to "Other Formats" + BUTTON_ENTER, # Select "Other Formats" + *([BUTTON_PAGE] * 2), # Go to "Open Stackbit" + BUTTON_ENTER, # Select "Open Stackbit" + BUTTON_PAGE, # Go to "Vertical" + BUTTON_ENTER, # Select "Vertical" + *([BUTTON_ENTER] * 4), # Advance 4 pages + BUTTON_PAGE, # Go to "Back" in Stackbit submenu + BUTTON_ENTER, # Select "Back" from Stackbit submenu + *([BUTTON_PAGE] * 2), # Go to "Back" in Other Formats + BUTTON_ENTER, # Select "Back" from Other Formats + BUTTON_PAGE, # Go to "Back" in mnemonic menu + BUTTON_ENTER, # Select "Back" + ], + ] + ctx = create_ctx(mocker, case[2], case[0], case[1]) + mnemonics = MnemonicsView(ctx) + mocker.spy(mnemonics, "stackbit") + mocker.spy(mnemonics, "_stackbit_vertical") + mocker.spy(mnemonics, "_stackbit_vertical_compact") + mnemonics.mnemonic() + mnemonics.stackbit.assert_called_once() + mnemonics._stackbit_vertical.assert_called_once() + mnemonics._stackbit_vertical_compact.assert_called_once() assert ctx.input.wait_for_button.call_count == len(case[2])