diff --git a/pyproject.toml b/pyproject.toml index b890b047..4e1c0cff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ [tool.poetry] name = "krux" -version = "24.09.beta0" +version = "24.09.beta2" description = "Open-source signing device firmware for Bitcoin" authors = ["Jeff S "] diff --git a/src/krux/camera.py b/src/krux/camera.py index 2c189b81..69c8b721 100644 --- a/src/krux/camera.py +++ b/src/krux/camera.py @@ -176,6 +176,14 @@ def disable_antiglare(self): sensor.skip_frames() self.antiglare_enabled = False + def toggle_antiglare(self): + """Toggles anti-glare mode and returns the new state""" + if self.antiglare_enabled: + self.disable_antiglare() + return False + self.enable_antiglare() + return True + def snapshot(self): """Helper to take a customized snapshot from sensor""" img = sensor.snapshot() @@ -193,59 +201,3 @@ def stop_sensor(self): """Stops capturing from sensor""" gc.collect() sensor.run(0) - - def capture_qr_code_loop(self, callback, flipped_x_coordinates=False): - """Captures either singular or animated QRs and parses their contents until - all parts of the message have been captured. The part data are then ordered - and assembled into one message and returned. - """ - self.initialize_run() - - parser = QRPartParser() - - prev_parsed_count = 0 - new_part = False - while True: - wdt.feed() - command = callback(parser.total_count(), parser.parsed_count(), new_part) - if not self.initialized: - # Ignores first callback as it may contain unintentional events - self.initialized = True - command = 0 - if command == 1: - break - new_part = False - - img = self.snapshot() - res = img.find_qrcodes() - - # different cases of lcd.display to show a progress bar on different devices! - if board.config["type"] == "m5stickv": - img.lens_corr(strength=1.0, zoom=0.56) - lcd.display(img, oft=(0, 0), roi=(68, 52, 185, 135)) - elif board.config["type"] == "amigo": - if flipped_x_coordinates: - lcd.display(img, oft=(40, 40)) - else: - lcd.display(img, oft=(120, 40)) # X and Y are swapped - elif board.config["type"] == "cube": - lcd.display(img, oft=(0, 0), roi=(0, 0, 224, 240)) - else: - lcd.display(img, oft=(0, 0), roi=(0, 0, 304, 240)) - - if len(res) > 0: - data = res[0].payload() - - parser.parse(data) - - if parser.processed_parts_count() > prev_parsed_count: - prev_parsed_count = parser.processed_parts_count() - new_part = True - - if parser.is_complete(): - break - self.stop_sensor() - - if parser.is_complete(): - return (parser.result(), parser.format) - return (None, None) diff --git a/src/krux/metadata.py b/src/krux/metadata.py index 24775d79..1182a064 100644 --- a/src/krux/metadata.py +++ b/src/krux/metadata.py @@ -19,5 +19,5 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -VERSION = "24.09.beta0" +VERSION = "24.09.beta2" SIGNER_PUBKEY = "03339e883157e45891e61ca9df4cd3bb895ef32d475b8e793559ea10a36766689b" diff --git a/src/krux/pages/__init__.py b/src/krux/pages/__init__.py index e87f4b4e..b25c97fc 100644 --- a/src/krux/pages/__init__.py +++ b/src/krux/pages/__init__.py @@ -45,7 +45,7 @@ STATUS_BAR_HEIGHT, ) from ..qr import to_qr_codes -from ..krux_settings import t, Settings, DefaultWallet +from ..krux_settings import t, Settings from ..sd_card import SDHandler MENU_CONTINUE = 0 @@ -55,7 +55,6 @@ ESC_KEY = 1 FIXED_KEYS = 3 # 'More' key only appears when there are multiple keysets -ANTI_GLARE_WAIT_TIME = 500 SHUTDOWN_WAIT_TIME = 300 TOGGLE_BRIGHTNESS = (BUTTON_PAGE, BUTTON_PAGE_PREV) @@ -83,7 +82,6 @@ class Page: def __init__(self, ctx, menu=None): self.ctx = ctx self.menu = menu - self._time_frame = 0 # context has its own keypad mapping in case touch is not used self.y_keypad_map = [] self.x_keypad_map = [] @@ -203,88 +201,6 @@ def capture_from_keypad( self.ctx.input.touch.clear_regions() return buffer - def capture_qr_code(self): - """Captures a singular or animated series of QR codes and displays progress to the user. - Returns the contents of the QR code(s). - """ - self._time_frame = time.ticks_ms() - - def callback(part_total, num_parts_captured, new_part): - # Turn on the light as long as the enter button is held down (M5stickV and Amigo) - if self.ctx.light: - if self.ctx.input.enter_value() == PRESSED: - self.ctx.light.turn_on() - else: - self.ctx.light.turn_off() - # If board don't have light, ENTER stops the capture - elif self.ctx.input.enter_event(): - return 1 - - # Anti-glare mode - if self.ctx.input.page_event() or ( - # Yahboom may have page or page_prev mapped to its single button - board.config["type"] == "yahboom" - and self.ctx.input.page_prev_event() - ): - if self.ctx.camera.has_antiglare(): - self._time_frame = time.ticks_ms() - self.ctx.display.to_portrait() - if not self.ctx.camera.antiglare_enabled: - self.ctx.camera.enable_antiglare() - self.ctx.display.draw_centered_text(t("Anti-glare enabled")) - else: - self.ctx.camera.disable_antiglare() - self.ctx.display.draw_centered_text(t("Anti-glare disabled")) - time.sleep_ms(ANTI_GLARE_WAIT_TIME) - self.ctx.display.to_landscape() - self.ctx.input.reset_ios_state() - return 0 - return 1 - - # Exit the capture loop with PAGE_PREV or TOUCH - if self.ctx.input.page_prev_event() or self.ctx.input.touch_event(): - return 1 - - # Indicate progress to the user that a new part was captured - if new_part: - self.ctx.display.to_portrait() - filled = self.ctx.display.width() * num_parts_captured - filled //= part_total - if board.config["type"] == "cube": - height = 225 - elif self.ctx.display.height() < 320: # M5StickV - height = 210 - elif self.ctx.display.height() > 320: # Amigo - height = 380 - else: - height = 305 - self.ctx.display.fill_rectangle( - 0, - height, - filled, - 15, - theme.fg_color, - ) - self.ctx.display.to_landscape() - - return 0 - - self.ctx.display.clear() - self.ctx.display.draw_centered_text(t("Loading Camera..")) - self.ctx.display.to_landscape() - code = None - qr_format = None - try: - code, qr_format = self.ctx.camera.capture_qr_code_loop( - callback, self.ctx.display.flipped_x_coordinates - ) - except: - print("Camera error") - if self.ctx.light: - self.ctx.light.turn_off() - self.ctx.display.to_portrait() - return (code, qr_format) - def display_qr_codes(self, data, qr_format, title=""): """Displays a QR code or an animated series of QR codes to the user, encoding them in the specified format diff --git a/src/krux/pages/encryption_ui.py b/src/krux/pages/encryption_ui.py index 1ac57062..911d8330 100644 --- a/src/krux/pages/encryption_ui.py +++ b/src/krux/pages/encryption_ui.py @@ -77,7 +77,11 @@ def load_key(self): def load_qr_encryption_key(self): """Loads and returns a key from a QR code""" - data, _ = self.capture_qr_code() + + from .qr_capture import QRCodeCapture + + qr_capture = QRCodeCapture(self.ctx) + data, _ = qr_capture.qr_capture_loop() if data is None: self.flash_error(t("Failed to load key")) return None diff --git a/src/krux/pages/home_pages/addresses.py b/src/krux/pages/home_pages/addresses.py index 7f166603..072b221e 100644 --- a/src/krux/pages/home_pages/addresses.py +++ b/src/krux/pages/home_pages/addresses.py @@ -142,7 +142,10 @@ def pre_scan_address(self): def scan_address(self, addr_type=0): """Handler for the 'receive' or 'change' menu item""" - data, qr_format = self.capture_qr_code() + from ..qr_capture import QRCodeCapture + + qr_capture = QRCodeCapture(self.ctx) + data, qr_format = qr_capture.qr_capture_loop() if data is None or qr_format != FORMAT_NONE: self.flash_error(t("Failed to load address")) return MENU_CONTINUE diff --git a/src/krux/pages/home_pages/home.py b/src/krux/pages/home_pages/home.py index 017eae2a..24515a71 100644 --- a/src/krux/pages/home_pages/home.py +++ b/src/krux/pages/home_pages/home.py @@ -204,7 +204,10 @@ def load_psbt(self): return (None, None, "") if load_method == LOAD_FROM_CAMERA: - data, qr_format = self.capture_qr_code() + from ..qr_capture import QRCodeCapture + + qr_capture = QRCodeCapture(self.ctx) + data, qr_format = qr_capture.qr_capture_loop() return (data, qr_format, "") # If load_method == LOAD_FROM_SD diff --git a/src/krux/pages/home_pages/sign_message_ui.py b/src/krux/pages/home_pages/sign_message_ui.py index 88bde2b7..429738ee 100644 --- a/src/krux/pages/home_pages/sign_message_ui.py +++ b/src/krux/pages/home_pages/sign_message_ui.py @@ -56,7 +56,10 @@ def load_message(self): return (None, None, "") if load_method == LOAD_FROM_CAMERA: - data, qr_format = self.capture_qr_code() + from ..qr_capture import QRCodeCapture + + qr_capture = QRCodeCapture(self.ctx) + data, qr_format = qr_capture.qr_capture_loop() return (data, qr_format, "") # If load_method == LOAD_FROM_SD diff --git a/src/krux/pages/home_pages/wallet_descriptor.py b/src/krux/pages/home_pages/wallet_descriptor.py index 39fe6774..4db8f1ba 100644 --- a/src/krux/pages/home_pages/wallet_descriptor.py +++ b/src/krux/pages/home_pages/wallet_descriptor.py @@ -84,7 +84,10 @@ def _load_wallet(self): persisted = False load_method = self.load_method() if load_method == LOAD_FROM_CAMERA: - wallet_data, qr_format = self.capture_qr_code() + from ..qr_capture import QRCodeCapture + + qr_capture = QRCodeCapture(self.ctx) + wallet_data, qr_format = qr_capture.qr_capture_loop() elif load_method == LOAD_FROM_SD: # Try to read the wallet output descriptor from a file on the SD card qr_format = FORMAT_NONE diff --git a/src/krux/pages/login.py b/src/krux/pages/login.py index 8260a5c2..431ac08e 100644 --- a/src/krux/pages/login.py +++ b/src/krux/pages/login.py @@ -302,7 +302,10 @@ def _encrypted_qr_code(self, data): def load_key_from_qr_code(self): """Handler for the 'via qr code' menu item""" - data, qr_format = self.capture_qr_code() + from .qr_capture import QRCodeCapture + + qr_capture = QRCodeCapture(self.ctx) + data, qr_format = qr_capture.qr_capture_loop() if data is None: self.flash_error(t("Failed to load mnemonic")) return MENU_CONTINUE diff --git a/src/krux/pages/qr_capture.py b/src/krux/pages/qr_capture.py new file mode 100644 index 00000000..74f93535 --- /dev/null +++ b/src/krux/pages/qr_capture.py @@ -0,0 +1,160 @@ +# The MIT License (MIT) + +# Copyright (c) 2021-2024 Krux contributors + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +import lcd +import board +import time +from . import Page +from ..input import PRESSED +from ..themes import theme +from ..qr import QRPartParser, FORMAT_UR +from ..wdt import wdt +from ..krux_settings import t + +ANTI_GLARE_WAIT_TIME = 500 + + +class QRCodeCapture(Page): + """UI to capture an encryption key""" + + def __init__(self, ctx): + super().__init__(ctx, None) + self.ctx = ctx + + def light_control(self): + """Controls the light based on the user input""" + if self.ctx.input.enter_value() == PRESSED: + self.ctx.light.turn_on() + else: + self.ctx.light.turn_off() + + def anti_glare_control(self): + """Controls the anti-glare based on the user input""" + self.ctx.display.to_portrait() + if self.ctx.camera.toggle_antiglare(): + self.ctx.display.draw_centered_text(t("Anti-glare enabled")) + else: + self.ctx.display.draw_centered_text(t("Anti-glare disabled")) + time.sleep_ms(ANTI_GLARE_WAIT_TIME) + self.ctx.display.to_landscape() + self.ctx.input.reset_ios_state() + + def update_progress(self, parser, index, previous_index): + """Updates the progress bar based on parts parsed""" + self.ctx.display.to_portrait() + height = {"cube": 225, "m5stickv": 210, "amigo": 380}.get( + board.config["type"], 305 + ) + if parser.format == FORMAT_UR: + filled = ( + self.ctx.display.width() * parser.parsed_count() + ) // parser.total_count() + self.ctx.display.fill_rectangle(0, height, filled, 15, theme.fg_color) + else: + block_size = self.ctx.display.width() // parser.total_count() + x_offset = block_size * index + self.ctx.display.fill_rectangle( + x_offset, height, block_size, 15, theme.highlight_color + ) + if previous_index is not None: + x_offset = block_size * previous_index + self.ctx.display.fill_rectangle( + x_offset, height, block_size, 15, theme.fg_color + ) + + self.ctx.display.to_landscape() + + def qr_capture_loop(self): + """Captures either singular or animated QRs and parses their contents""" + + self.ctx.display.clear() + self.ctx.display.draw_centered_text(t("Loading Camera..")) + self.ctx.display.to_landscape() + self.ctx.camera.initialize_run() + + parser = QRPartParser() + prev_parsed_count = 0 + new_part = None + previous_part = None + + self.ctx.input.reset_ios_state() + while True: + wdt.feed() + + if self.ctx.light: + self.light_control() + elif self.ctx.input.enter_event(): + break + + # Anti-glare mode + if self.ctx.input.page_event() or ( + board.config["type"] == "yahboom" and self.ctx.input.page_prev_event() + ): + if self.ctx.camera.has_antiglare(): + self.anti_glare_control() + else: + break + + # Exit the capture loop with PAGE_PREV or TOUCH + if self.ctx.input.page_prev_event() or self.ctx.input.touch_event(): + break + + if new_part is not None and new_part != previous_part: + self.update_progress(parser, new_part, previous_part) + previous_part = new_part + new_part = None + + img = self.ctx.camera.snapshot() + res = img.find_qrcodes() + + if board.config["type"] == "m5stickv": + img.lens_corr(strength=1.0, zoom=0.56) + lcd.display(img, oft=(0, 0), roi=(68, 52, 185, 135)) + elif board.config["type"] == "amigo": + x_offset = 40 if self.ctx.display.flipped_x_coordinates else 120 + lcd.display(img, oft=(x_offset, 40)) + elif board.config["type"] == "cube": + lcd.display(img, oft=(0, 0), roi=(0, 0, 224, 240)) + else: + lcd.display(img, oft=(0, 0), roi=(0, 0, 304, 240)) + + if res: + data = res[0].payload() + new_part = parser.parse(data) + + if ( + parser.format == FORMAT_UR + and parser.processed_parts_count() > prev_parsed_count + ): + prev_parsed_count = parser.processed_parts_count() + new_part = True + + if parser.is_complete(): + break + + self.ctx.camera.stop_sensor() + if self.ctx.light: + self.ctx.light.turn_off() + self.ctx.display.to_portrait() + + if parser.is_complete(): + return parser.result(), parser.format + return None, None diff --git a/src/krux/pages/wallet_settings.py b/src/krux/pages/wallet_settings.py index 62eccda3..e25dc2c7 100644 --- a/src/krux/pages/wallet_settings.py +++ b/src/krux/pages/wallet_settings.py @@ -82,7 +82,10 @@ def _load_passphrase(self): ) def _load_qr_passphrase(self): - data, _ = self.capture_qr_code() + from .qr_capture import QRCodeCapture + + qr_capture = QRCodeCapture(self.ctx) + data, _ = qr_capture.qr_capture_loop() if data is None: self.flash_error(t("Failed to load passphrase")) return MENU_CONTINUE diff --git a/src/krux/qr.py b/src/krux/qr.py index abf94831..3d54f095 100644 --- a/src/krux/qr.py +++ b/src/krux/qr.py @@ -142,6 +142,7 @@ def parse(self, data): part, index, total = parse_pmofn_qr_part(data) self.parts[index] = part self.total = total + return index elif self.format == FORMAT_UR: if not self.decoder: from ur.ur_decoder import URDecoder @@ -154,6 +155,8 @@ def parse(self, data): part, index, total = parse_bbqr(data) self.parts[index] = part self.total = total + return index + return None def is_complete(self): """Returns a boolean indicating whether or not enough parts have been parsed"""