diff --git a/.gitignore b/.gitignore index 9f2e8485..e5df4958 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store __pycache__/ +src/seedsigner.egg-info/ diff --git a/README.md b/README.md index e550efa9..ed89842a 100644 --- a/README.md +++ b/README.md @@ -169,3 +169,7 @@ sudo reboot It will take about a minute after the Pi is powered on for the GUI to launch -- be patient! _Reminder: If you used option #2, [return the guide](docs/usb_relay.md) to remove the internet access over USB configuration._ + + +## Run the tests +see: [tests/README.md](tests/README.md) diff --git a/docs/12_word_seed.pdf b/docs/12_word_seed.pdf index a5349143..9d42e600 100644 Binary files a/docs/12_word_seed.pdf and b/docs/12_word_seed.pdf differ diff --git a/docs/24_word_seed.pdf b/docs/24_word_seed.pdf index 97176f12..a68a70ab 100644 Binary files a/docs/24_word_seed.pdf and b/docs/24_word_seed.pdf differ diff --git a/docs/qr_code_template.py b/docs/qr_code_template.py index a70916b4..0ddd092f 100644 --- a/docs/qr_code_template.py +++ b/docs/qr_code_template.py @@ -1,6 +1,13 @@ import math def generate_qr_template(qr_size, block_size=5, show_timing_marks=False): + if qr_size == 29: + num_words = 24 + word_cols = 2 + else: + num_words = 12 + word_cols = 1 + html = """ @@ -9,6 +16,7 @@ def generate_qr_template(qr_size, block_size=5, show_timing_marks=False): """ - if qr_size == 21: - html += f"""
{qr_size}x{qr_size} QR Code
""" - elif qr_size == 25: - html += f"""
12-word Seed
""" - else: - html += f"""
24-word Seed
""" + + html += """ + + + +
+ """ + for i in range(1, num_words + 1): + if i == 13: + html += """ + + """ + html += f"""
+ {i}: ____________________________ +
+ """ + html += "
" y_names = "A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,X,Y,Z" - html += """""" + html += """
""" html += f"""""" for j in range(0, math.ceil(qr_size/block_size)): html += f"""""" diff --git a/docs/qr_code_template_25x25.html b/docs/qr_code_template_25x25.html index a2cb69e0..01999e17 100644 --- a/docs/qr_code_template_25x25.html +++ b/docs/qr_code_template_25x25.html @@ -6,6 +6,7 @@ -
12-word Seed
{j + 1}
+ +
12345
+ +
+
+ 1: ____________________________ +
+
+ 2: ____________________________ +
+
+ 3: ____________________________ +
+
+ 4: ____________________________ +
+
+ 5: ____________________________ +
+
+ 6: ____________________________ +
+
+ 7: ____________________________ +
+
+ 8: ____________________________ +
+
+ 9: ____________________________ +
+
+ 10: ____________________________ +
+
+ 11: ____________________________ +
+
+ 12: ____________________________ +
+
diff --git a/docs/qr_code_template_29x29.html b/docs/qr_code_template_29x29.html index 7767c9fd..281d8c85 100644 --- a/docs/qr_code_template_29x29.html +++ b/docs/qr_code_template_29x29.html @@ -6,6 +6,7 @@ -
24-word Seed
12345
A
+ +
123456
+ + +
+
+ 1: ____________________________ +
+
+ 2: ____________________________ +
+
+ 3: ____________________________ +
+
+ 4: ____________________________ +
+
+ 5: ____________________________ +
+
+ 6: ____________________________ +
+
+ 7: ____________________________ +
+
+ 8: ____________________________ +
+
+ 9: ____________________________ +
+
+ 10: ____________________________ +
+
+ 11: ____________________________ +
+
+ 12: ____________________________ +
+ +
+
+ 13: ____________________________ +
+
+ 14: ____________________________ +
+
+ 15: ____________________________ +
+
+ 16: ____________________________ +
+
+ 17: ____________________________ +
+
+ 18: ____________________________ +
+
+ 19: ____________________________ +
+
+ 20: ____________________________ +
+
+ 21: ____________________________ +
+
+ 22: ____________________________ +
+
+ 23: ____________________________ +
+
+ 24: ____________________________ +
+
diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..de19c9f0 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 51d1a2c4..27f64fed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ embit==0.4.3 -imutils==0.5.4 +picamera==1.13 Pillow==8.2.0 pyzbar==0.1.8 qrcode==6.1 diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..66fa1c99 --- /dev/null +++ b/setup.py @@ -0,0 +1,26 @@ +import setuptools + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setuptools.setup( + name="seedsigner", + version="0.4.1", + author="SeedSigner", + author_email="author@example.com", + description="Build an offline, airgapped Bitcoin signing device for less than $50!", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/SeedSigner/seedsigner", + project_urls={ + "Bug Tracker": "https://github.com/SeedSigner/seedsigner/issues", + }, + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + package_dir={"": "src"}, + packages=setuptools.find_packages(where="src"), + python_requires=">=3.6", +) \ No newline at end of file diff --git a/src/main.py b/src/main.py index 1bb2a4b2..deb8df17 100644 --- a/src/main.py +++ b/src/main.py @@ -8,5 +8,9 @@ from seedsigner.controller import Controller config = configparser.ConfigParser() config.read("settings.ini") -controller = Controller(config) +# One-time setup to intialize the one and only Controller +Controller.configure_instance(config) + +# Get the one and only Controller instance and start our main loop +controller = Controller.get_instance() controller.start() diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index ff9ca6f8..bc5534d3 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -11,37 +11,78 @@ from .models import (SeedStorage, SpecterDesktopWallet, BlueWallet, SparrowWallet, GenericUR2Wallet, Wallet) -class Controller: - - VERSION = "0.4.2" - def __init__(self, config) -> None: - controller = self + +class Controller: + """ + The Controller is a globally available singleton that maintains SeedSigner state. + + It only makes sense to ever have a single Controller instance so it is + implemented here as a singleton. One departure from the typical singleton pattern + is the addition of a `configure_instance()` call to pass run-time settings into + the Controller. + + Any code that needs to interact with the one and only Controller can just run: + ``` + from seedsigner.controller import Controller + controller = Controller.get_instance() + ``` + Note: In many/most cases you'll need to do the Controller import within a method + rather than at the top in order avoid circular imports. + """ + VERSION = "0.4.3" + + _instance = None + + + def __init__(self): + # Singleton pattern must prevent normal instantiation + raise Exception("Cannot directly instantiate the Controller. Access via Controller.get_instance()") + + + @classmethod + def get_instance(cls): + # This is the only way to access the one and only Controller + if cls._instance: + return cls._instance + else: + raise Exception("Must call Controller.configure_instance(config) first") + + + @classmethod + def configure_instance(cls, config=None): + # Must be called before the first get_instance() call + if cls._instance: + raise Exception("Instance already configured") + + # Instantiate the one and only Controller instance + controller = cls.__new__(cls) + cls._instance = controller # settings - self.DEBUG = config.getboolean("system", "DEBUG") - self.color = config["display"]["TEXT_COLOR"] + controller.DEBUG = config.getboolean("system", "DEBUG") + controller.color = config["display"]["TEXT_COLOR"] # Input Buttons - self.buttons = Buttons() + controller.buttons = Buttons() # models - self.storage = SeedStorage() - self.wallet_klass = globals()["SpecterDesktopWallet"] - self.wallet = self.wallet_klass() + controller.storage = SeedStorage() + controller.wallet_klass = globals()["SpecterDesktopWallet"] + controller.wallet = controller.wallet_klass() # Views - self.menu_view = MenuView(controller) - self.seed_tools_view = SeedToolsView(controller) - self.io_test_view = IOTestView(controller) - self.signing_tools_view = SigningToolsView(controller, self.storage) - self.settings_tools_view = SettingsToolsView(controller) + controller.menu_view = MenuView() + controller.seed_tools_view = SeedToolsView() + controller.io_test_view = IOTestView() + controller.signing_tools_view = SigningToolsView(controller.storage) + controller.settings_tools_view = SettingsToolsView() # Then start seperate background camera process with two queues for communication # CameraProcess handles connecting to camera hardware and passing back barcode data via from camera queue - self.from_camera_queue = Queue() - self.to_camera_queue = Queue() - p = Process(target=CameraProcess.start, args=(self.from_camera_queue, self.to_camera_queue)) + controller.from_camera_queue = Queue() + controller.to_camera_queue = Queue() + p = Process(target=CameraProcess.start, args=(controller.from_camera_queue, controller.to_camera_queue)) p.start() @@ -88,6 +129,8 @@ class Controller: ret_val = self.show_generate_last_word_tool() elif ret_val == Path.DICE_GEN_SEED: ret_val = self.show_create_seed_with_dice_tool() + elif ret_val == Path.IMAGE_GEN_SEED: + ret_val = self.show_create_seed_with_image_tool() elif ret_val == Path.SAVE_SEED: ret_val = self.show_store_a_seed_tool() elif ret_val == Path.PASSPHRASE_SEED: @@ -141,9 +184,9 @@ class Controller: # display menu to select 12 or 24 word seed for last word ret_val = self.menu_view.display_12_24_word_menu("... [ Return to Seed Tools ]") if ret_val == Path.SEED_WORD_12: - seed_phrase = self.seed_tools_view.display_gather_words_screen(11) + seed_phrase = self.seed_tools_view.display_manual_seed_entry(11) elif ret_val == Path.SEED_WORD_24: - seed_phrase = self.seed_tools_view.display_gather_words_screen(23) + seed_phrase = self.seed_tools_view.display_manual_seed_entry(23) else: return Path.SEED_TOOLS_SUB_MENU @@ -206,6 +249,41 @@ class Controller: return Path.MAIN_MENU + def show_create_seed_with_image_tool(self) -> int: + seed_phrase = [] + ret_val = True + + while True: + (reshoot, seed_phrase) = self.seed_tools_view.seed_phrase_from_camera_image() + if reshoot: + # Relaunch into another image capture cycle + continue + + if len(seed_phrase) > 0: + break + else: + return Path.SEED_TOOLS_SUB_MENU + + # display seed phrase (24 words) + while True: + ret_val = self.seed_tools_view.display_seed_phrase(seed_phrase, show_qr_option=True) + if ret_val == True: + break + else: + # Start over + return self.show_create_seed_with_image_tool() + + # Ask to save seed + if self.storage.slot_avaliable(): + r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Save Seed?") + if r == 1: #Yes + slot_num = self.menu_view.display_saved_seed_menu(self.storage,2,None) + if slot_num in (1,2,3): + self.storage.save_seed_phrase(seed_phrase, slot_num) + self.menu_view.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Main Menu") + input = self.buttons.wait_for([B.KEY_RIGHT]) + + return Path.MAIN_MENU ### Store a seed (temp) Menu def show_store_a_seed_tool(self): @@ -234,9 +312,9 @@ class Controller: # display menu to select 12 or 24 word seed for last word ret_val = self.menu_view.display_qr_12_24_word_menu("... [ Return to Seed Tools ]") if ret_val == Path.SEED_WORD_12: - seed_phrase = self.seed_tools_view.display_gather_words_screen(12) + seed_phrase = self.seed_tools_view.display_manual_seed_entry(12) elif ret_val == Path.SEED_WORD_24: - seed_phrase = self.seed_tools_view.display_gather_words_screen(24) + seed_phrase = self.seed_tools_view.display_manual_seed_entry(24) elif ret_val == Path.SEED_WORD_QR: seed_phrase = self.seed_tools_view.read_seed_phrase_qr() else: @@ -321,7 +399,7 @@ class Controller: slot_num = ret_val # display a tool to pick letters/numbers to make a passphrase - passphrase = self.seed_tools_view.display_gather_passphrase_screen(self.storage.get_passphrase(slot_num)) + passphrase = self.seed_tools_view.draw_passphrase_keyboard_entry(existing_passphrase=self.storage.get_passphrase(slot_num)) if len(passphrase) == 0: return Path.SEED_TOOLS_SUB_MENU @@ -359,9 +437,9 @@ class Controller: # display menu to select 12 or 24 word seed for last word ret_val = self.menu_view.display_qr_12_24_word_menu("... [ Return to Sign Tools ]") if ret_val == Path.SEED_WORD_12: - seed_phrase = self.seed_tools_view.display_gather_words_screen(12) + seed_phrase = self.seed_tools_view.display_manual_seed_entry(12) elif ret_val == Path.SEED_WORD_24: - seed_phrase = self.seed_tools_view.display_gather_words_screen(24) + seed_phrase = self.seed_tools_view.display_manual_seed_entry(24) elif ret_val == Path.SEED_WORD_QR: seed_phrase = self.seed_tools_view.read_seed_phrase_qr() else: @@ -381,7 +459,7 @@ class Controller: r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Optional Passphrase?") if r == 1: # display a tool to pick letters/numbers to make a passphrase - passphrase = self.seed_tools_view.display_gather_passphrase_screen() + passphrase = self.seed_tools_view.draw_passphrase_keyboard_entry() if len(passphrase) == 0 or passphrase == "-1": passphrase = "" self.menu_view.draw_modal(["No passphrase added", "to seed words"], "", "Left to Exit, Right to Continue") @@ -430,9 +508,9 @@ class Controller: # display menu to select 12 or 24 word seed for last word ret_val = self.menu_view.display_qr_12_24_word_menu("... [ Return to Main Menu ]") if ret_val == Path.SEED_WORD_12: - seed_phrase = self.seed_tools_view.display_gather_words_screen(12) + seed_phrase = self.seed_tools_view.display_manual_seed_entry(12) elif ret_val == Path.SEED_WORD_24: - seed_phrase = self.seed_tools_view.display_gather_words_screen(24) + seed_phrase = self.seed_tools_view.display_manual_seed_entry(24) elif ret_val == Path.SEED_WORD_QR: seed_phrase = self.seed_tools_view.read_seed_phrase_qr() else: @@ -452,7 +530,7 @@ class Controller: r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Optional Passphrase?") if r == 1: # display a tool to pick letters/numbers to make a passphrase - passphrase = self.seed_tools_view.display_gather_passphrase_screen() + passphrase = self.seed_tools_view.draw_passphrase_keyboard_entry() if len(passphrase) == 0 or passphrase == "-1": passphrase = "" self.menu_view.draw_modal(["No passphrase added", "to seed words"], "", "Left to Exit, Right to Continue") diff --git a/src/seedsigner/helpers/__init__.py b/src/seedsigner/helpers/__init__.py index 242c25db..4120967a 100644 --- a/src/seedsigner/helpers/__init__.py +++ b/src/seedsigner/helpers/__init__.py @@ -1,5 +1,6 @@ from .buttons import * from .camera_process import * +from .keyboard import Keyboard, TextEntryDisplay from .path import * from .qr import * from .ST7789 import * \ No newline at end of file diff --git a/src/seedsigner/helpers/buttons.py b/src/seedsigner/helpers/buttons.py index 875e6865..80dd9d03 100644 --- a/src/seedsigner/helpers/buttons.py +++ b/src/seedsigner/helpers/buttons.py @@ -1,8 +1,9 @@ import RPi.GPIO as GPIO import time -class Buttons: + +class Buttons: KEY_UP_PIN = 6 KEY_DOWN_PIN = 19 KEY_LEFT_PIN = 5 @@ -30,40 +31,83 @@ class Buttons: self.add_events([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS, B.KEY_LEFT, B.KEY_RIGHT, B.KEY1, B.KEY2, B.KEY3]) - return + # Track state over time so we can apply input delays/ignores as needed + self.cur_input = None # Track which direction or button was last pressed + self.cur_input_started = None # Track when that input began + self.last_input_time = int(time.time() * 1000) # How long has it been since the last input? + self.first_repeat_threshold = 175 # Long-press time required before returning continuous input + self.next_repeat_threshold = 250 # Amount of time where we no longer consider input a continuous hold - def wait_for(self, keys = [], check_release = True, release_keys = []) -> int: + + def wait_for(self, keys=[], check_release=True, release_keys=[]) -> int: if not release_keys: release_keys = keys self.override_ind = False while True: - for i in keys: - if check_release == False or ((check_release == True and i in release_keys and B.release_lock == True) or check_release == True and i not in release_keys): + for key in keys: + if not check_release or ((check_release and key in release_keys and B.release_lock) or check_release and key not in release_keys): # when check release is False or the release lock is released (True) - if self.GPIO.input(i) == GPIO.LOW or self.override_ind == True: + if self.GPIO.input(key) == GPIO.LOW or self.override_ind: B.release_lock = False - if self.override_ind == True: + if self.override_ind: self.override_ind = False return B.OVERRIDE - return i + + if self.cur_input != key: + self.cur_input = key + self.cur_input_started = int(time.time() * 1000) # in milliseconds + self.last_input_time = self.cur_input_started + return key + + else: + # Still pressing the same input + cur_time = int(time.time() * 1000) + + if cur_time - self.last_input_time > self.next_repeat_threshold: + # Too much time has elapsed to consider this the same + # continuous input. Treat as a new separate press. + self.cur_input_started = cur_time + self.last_input_time = cur_time + return key + + elif cur_time - self.cur_input_started > self.first_repeat_threshold: + # We're good to relay this immediately as continuous + # input. + self.last_input_time = cur_time + return key + + else: + # We're not yet at the first repeat threshold; triggering + # a key now would be too soon and yields a bad user + # experience when only a single click was intended but + # a second input is processed because of race condition + # against human response time to release the button. + # So there has to be a delay before we allow the first + # continuous repeat to register. So we'll ignore this + # round's input and **won't update any of our + # timekeeping vars**. But once we cross the threshold, + # we let the repeats fly. + pass + time.sleep(0.01) # wait 10 ms to give CPU chance to do other things - def add_events(self, keys = []): - for i in keys: - GPIO.add_event_detect(i, self.GPIO.RISING, callback=Buttons.rising_callback) + + def add_events(self, keys=[]): + for key in keys: + GPIO.add_event_detect(key, self.GPIO.RISING, callback=Buttons.rising_callback) + def rising_callback(channel): B.release_lock = True - return + def trigger_override(self, force_release = False) -> bool: - if force_release == True: + if force_release: B.release_lock = True - if self.override_ind == False: + if not self.override_ind: self.override_ind = True return True - return False def force_release(self) -> bool: @@ -71,14 +115,14 @@ class Buttons: return True def check_for_low(self, key) -> bool: - if self.GPIO.input(key) == self.GPIO.LOW: - return True - return False + return self.GPIO.input(key) == self.GPIO.LOW + + # class used as short hand for static button/channel lookup values - +# TODO: Implement `release_lock` functionality as a global somewhere. Mixes up design +# patterns to have a static constants class plus a settable global value. class B: - KEY_UP = 6 KEY_DOWN = 19 KEY_LEFT = 5 diff --git a/src/seedsigner/helpers/camera_process.py b/src/seedsigner/helpers/camera_process.py index 8cd82679..932162d2 100644 --- a/src/seedsigner/helpers/camera_process.py +++ b/src/seedsigner/helpers/camera_process.py @@ -1,58 +1,144 @@ # External Dependencies from multiprocessing import Queue from threading import Timer +import io import time +import traceback +import operator -class CameraProcess: - def start(out_queue, in_queue): +class CameraProcess(): - print("CameraProcess start") - - from imutils.video import VideoStream + def scan_qr_code(in_queue, out_queue): from pyzbar import pyzbar + from .pivideostream import PiVideoStream + try: + print("Instantiate PiVideoStream start") + start_time = int(time.time() * 1000) + video_stream = PiVideoStream(resolution=(512, 384),framerate=12) + end_time = int(time.time() * 1000) + print(f"Instantiate PiVideoStream finish: {end_time - start_time}ms") - print("CameraProcess finish import") + video_stream.start() - running = True + msg = [""] - while running == True: + while True: + # Loop the reader until we get a result or receive "stop" + frame = video_stream.read() - camera_on = False + if frame is None: + # Camera isn't returning data yet + time.sleep(0.1) + continue + + barcodes = pyzbar.decode(frame) + for barcode in barcodes: + data = barcode.data.decode("utf-8") + out_queue.put([data]) + break + if len(barcodes) == 0: + out_queue.put(["nodata"]) + + try: + # Get any updates from the message queue, but don't wait + msg = in_queue.get(block=False) + except: + pass + + if msg[0] == "stop": + break + finally: + try: + video_stream.stop() + except: + pass + + + + def capture_single_frame(in_queue, out_queue): + from PIL import Image + import picamera + + print("Instantiate PiCamera start") + start_time = int(time.time() * 1000) + camera = picamera.PiCamera(resolution=(720, 480), framerate=8) + end_time = int(time.time() * 1000) + print(f"Instantiate PiCamera finish: {end_time - start_time}ms") + + try: + print("camera ready") + out_queue.put(["ready"]) + + # Wait for the "click" command + while True: + try: + msg = in_queue.get(block=False) + break + except: + time.sleep(0.1) + + if msg[0] == "stop": + print("Received 'stop'") + return + + elif msg[0] == "click": + print("Received 'click'") + + # Wait for the automatic gain control to settle + time.sleep(0.25) + # Now fix the values + camera.shutter_speed = camera.exposure_speed + camera.exposure_mode = 'off' + g = camera.awb_gains + camera.awb_mode = 'off' + camera.awb_gains = g + + stream = io.BytesIO() + camera.capture(stream, format='jpeg') + + # "Rewind" the stream to the beginning so we can read its content + stream.seek(0) + + out_queue.put([Image.open(stream)]) + + except Exception as e: + traceback.print_exc() + + finally: + camera.close() + print("Cleaned up capture_single_frame") + + + @classmethod + def start(cls, out_queue, in_queue): + print("CameraProcess start") + start_time = int(time.time() * 1000) + from .pivideostream import PiVideoStream + end_time = int(time.time() * 1000) + print(f"CameraProcess finish import: {end_time - start_time}ms") + + is_running = True + + while is_running: + is_camera_on = False msg = [] msg = in_queue.get() if msg[0] == "start": - print("start camera!!") - vs = VideoStream(usePiCamera=True,resolution=(512, 384),framerate=12).start() # For Pi Camera - time.sleep(2.0) + CameraProcess.scan_qr_code(in_queue, out_queue) - msg[0] = "" - camera_on = True - - while camera_on: - frame = vs.read() - barcodes = pyzbar.decode(frame) - for barcode in barcodes: - data = barcode.data.decode("utf-8") - out_queue.put([data]) - break - if len(barcodes) == 0: - out_queue.put(["nodata"]) - - try: - msg = in_queue.get(False) - except: - pass - - if msg[0] == "stop": - vs.stop() - camera_on = False + elif msg[0] == "single_frame": + CameraProcess.capture_single_frame(in_queue, out_queue) elif msg[0] == "stop": print("stop camera!!") + time.sleep(0.25) # No need to poll all that frequently + + + class CameraPoll(object): def __init__(self, interval, function, *args, **kwargs): diff --git a/src/seedsigner/helpers/keyboard.py b/src/seedsigner/helpers/keyboard.py new file mode 100644 index 00000000..880049ce --- /dev/null +++ b/src/seedsigner/helpers/keyboard.py @@ -0,0 +1,430 @@ +from dataclasses import dataclass + +from . import B + + + +class Keyboard: + WRAP_TOP = "wrap_top" + WRAP_BOTTOM = "wrap_bottom" + WRAP_LEFT = "wrap_left" + WRAP_RIGHT = "wrap_right" + + EXIT_TOP = "exit_top" + EXIT_BOTTOM = "exit_bottom" + EXIT_LEFT = "exit_left" + EXIT_RIGHT = "exit_right" + EXIT_DIRECTIONS = [EXIT_TOP, EXIT_BOTTOM, EXIT_LEFT, EXIT_RIGHT] + + ENTER_TOP = "enter_top" + ENTER_BOTTOM = "enter_bottom" + ENTER_LEFT = "enter_left" + ENTER_RIGHT = "enter_right" + + REGULAR_KEY_FONT = "regular" + COMPACT_KEY_FONT = "compact" + + KEY_BACKSPACE = { + "letter": "del", + "font": COMPACT_KEY_FONT, + "size": 2, + } + KEY_PREVIOUS_PAGE = { + "letter": "prev" + } + ADDITIONAL_KEYS = { + KEY_BACKSPACE["letter"]: KEY_BACKSPACE, + KEY_PREVIOUS_PAGE["letter"]: KEY_PREVIOUS_PAGE, + } + + @dataclass + class Key: + """ + Simple python3.x dataclass (akin to a strut) to store info about each + individual key in the keyboard and its state. Attrs with defaults must be + listed last. + """ + letter: str + screen_x: int + screen_y: int + keyboard: any + size: int = 1 + is_active: bool = True + is_selected: bool = False + is_additional_key: bool = False + + def render_key(self): + font = self.keyboard.font + if self.letter in Keyboard.ADDITIONAL_KEYS: + if Keyboard.ADDITIONAL_KEYS[self.letter]["font"] == Keyboard.COMPACT_KEY_FONT: + font = self.keyboard.additonal_key_compact_font + + outline_color = "#333" + if not self.is_active: + rect_color = self.keyboard.background_color + font_color = "#666" # Show the letter but render as gray + if self.is_selected: + # Inactive, selected just gets highlighted outline + outline_color = self.keyboard.highlight_color + elif self.is_selected: + rect_color = self.keyboard.highlight_color # Render solid background with the UI's hero color + font_color = self.keyboard.background_color + else: + rect_color = self.keyboard.background_color + font_color = self.keyboard.highlight_color + + self.keyboard.draw.rectangle((self.screen_x, self.screen_y, self.screen_x + self.keyboard.x_width * self.size, self.screen_y + self.keyboard.y_height), outline=outline_color, fill=rect_color) + tw, th = self.keyboard.draw.textsize(self.letter, font=font) + self.keyboard.draw.text((self.screen_x + int((self.keyboard.x_width * self.size - tw) / 2), self.screen_y + int((self.keyboard.y_height - th)/2)), self.letter, fill=font_color, font=font) + + + + def __init__(self, + draw, + charset="1234567890abcdefghijklmnopqrstuvwxyz", + selected_char="a", + rows=4, + cols=10, + rect=(0,40, 240,240), + font=None, + additional_keys=[KEY_BACKSPACE], + auto_wrap=[WRAP_TOP, WRAP_BOTTOM, WRAP_LEFT, WRAP_RIGHT], + render_now=True): + """ + `auto_wrap` specifies which edges the keyboard is allowed to loop back when + navigating past the end. + """ + + # Import here to avoid circular import problems + from seedsigner.views import View + + self.draw = draw + self.charset = charset + self.rows = rows + self.cols = cols + self.rect = rect + if font: + self.font = font + else: + self.font = View.ROBOTOCONDENSED_REGULAR_24 + self.auto_wrap = auto_wrap + self.background_color = "black" + self.highlight_color = View.color + + # Does the specified layout work? + additional_key_spaces = 0 + for additional_key in additional_keys: + additional_key_spaces += additional_key["size"] # e.g. backspace takes up 2 slots + if rows * cols < len(charset) + additional_key_spaces: + raise Exception(f"charset will not fit in a {rows}x{cols} layout | additional_keys: {additional_keys}") + + if not selected_char: + raise Exception("`selected_char` cannot be None") + + # Set up the rendering and state params + self.active_keys = list(self.charset) + self.additonal_key_compact_font = View.ROBOTOCONDENSED_BOLD_18 + self.x_start = rect[0] + self.y_start = rect[1] + self.x_gap = 1 + self.x_width = int((rect[2] - rect[0]) / cols) - self.x_gap + self.y_gap = 6 + self.y_height = int((rect[3] - rect[1]) / rows) - self.y_gap + + # Two-dimensional list of Key obj row data + self.keys = [] + self.selected_key = {"x": 0, "y": 0} # Indices in the `keys` 2D list + cur_y = self.y_start + for i in range(0, rows): + cur_row = [] + cur_x = self.x_start + for j, letter in enumerate(charset[i*cols:(i+1)*cols]): + is_selected = False + if letter == selected_char: + is_selected = True + self.selected_key["y"] = i + self.selected_key["x"] = j + cur_row.append(self.Key( + letter=letter, + screen_x=cur_x, + screen_y=cur_y, + is_selected=is_selected, + keyboard=self + )) + cur_x += self.x_width + self.x_gap + self.keys.append(cur_row) + if i < rows -1: + # increment to the next row and continue + cur_y += self.y_height + self.y_gap + else: + # It's the last row; add the additional keys at the end + for additional_key in additional_keys: + self.keys[-1].append(self.Key( + letter=additional_key["letter"], + screen_x=cur_x, + screen_y=cur_y, + keyboard=self, + size=additional_key["size"], + is_additional_key=True, + )) + cur_x += self.x_width + self.x_gap + + if render_now: + # Render the keys + self.render_keys() + + # Render the initial highlighted character + self.update_from_input(input=None) + + + def update_active_keys(self, active_keys): + self.active_keys = active_keys + for i, row_keys in enumerate(self.keys): + for j, key in enumerate(row_keys): + if key.letter not in self.active_keys and key.letter not in Keyboard.ADDITIONAL_KEYS: + # Note: ADDITIONAL_KEYS are never deactivated. + key.is_active = False + else: + key.is_active = True + + + def render_keys(self, selected_letter=None): + """ + Renders just the keys of the keyboard. Useful when you need to redraw just + that section, as in when changing `active_keys` or swapping to alternate + charsets (e.g. alpha to special symbols). + + Does NOT call View.DispShowImage to avoid multiple calls on the same screen. + """ + # Start with a clear screen + self.draw.rectangle(self.rect, outline=0, fill=0) + + for i, row_keys in enumerate(self.keys): + for j, key in enumerate(row_keys): + if selected_letter and key.letter == selected_letter: + key.is_selected = True + self.selected_key["y"] = i + self.selected_key["x"] = j + key.render_key() + + + def update_from_input(self, input, enter_from=None): + """ + Managing code must handle its own input/update loop since other action buttons + will be active on the same screen outside of the keyboard rect (e.g. "Ok", + "Back", etc). Pass relevant input here to update the keyboard. + + `enter_from` tells the keyboard that the external UI has caused a loop back + navigation. + (e.g. pressing up from a submit button below the keyboard = ENTER_BOTTOM) + + Returns the character currently highlighted or one of the EXIT_* codes if the + user has navigated off the keyboard past an edge that is not in `auto_wrap`. + + Does NOT call View.DispShowImage to avoid multiple calls on the same screen. + """ + key = self.keys[self.selected_key["y"]][self.selected_key["x"]] + + # Before we update, undo our previously self.selected_key key + key.is_selected = False + key.render_key() + + if input == B.KEY_RIGHT: + self.selected_key["x"] += 1 + if self.selected_key["x"] == len(self.keys[self.selected_key["y"]]): + if Keyboard.WRAP_RIGHT in self.auto_wrap: + # Loop it back to the right side + self.selected_key["x"] = 0 + else: + # Undo selection change and notify controlling loop that we've left + # the keyboard + self.selected_key["x"] -= 1 + return Keyboard.EXIT_RIGHT + + elif input == B.KEY_LEFT: + self.selected_key["x"] -= 1 + if self.selected_key["x"] < 0: + if Keyboard.WRAP_LEFT in self.auto_wrap: + # Loop it back to the left side + self.selected_key["x"] = len(self.keys[self.selected_key["y"]]) - 1 + else: + # Undo selection change and notify controlling loop that we've left + # the keyboard + self.selected_key["x"] += 1 + return Keyboard.EXIT_LEFT + + elif input == B.KEY_DOWN: + self.selected_key["y"] += 1 + if self.selected_key["y"] == len(self.keys): + if Keyboard.WRAP_BOTTOM in self.auto_wrap: + # Loop it back to the top + self.selected_key["y"] = 0 + else: + # Undo selection change and notify controlling loop that we've left + # the keyboard + self.selected_key["y"] -= 1 + return Keyboard.EXIT_BOTTOM + + elif self.selected_key["x"] >= len(self.keys[self.selected_key["y"]]): + # We're moving into the bottom line but there's no key directly below. + if self.selected_key["x"] - 1 == len(self.keys[self.selected_key["y"]]) - 1 and \ + self.keys[self.selected_key["y"]][-1].size == 2: + # The last, adjacent key in this row is a double. Go ahead and select it + self.selected_key["x"] = self.selected_key["x"] - 1 + + else: + if Keyboard.WRAP_BOTTOM in self.auto_wrap: + # This line is too short to land here + self.selected_key["y"] = 0 + else: + # Undo selection change and notify controlling loop that we've left + # the keyboard + self.selected_key["y"] -= 1 + return Keyboard.EXIT_BOTTOM + + elif input == B.KEY_UP: + self.selected_key["y"] -= 1 + if self.selected_key["y"] < 0: + if Keyboard.WRAP_TOP in self.auto_wrap: + # Loop it back to the bottom + self.selected_key["y"] = len(self.keys) - 1 + else: + # Undo selection change and notify controlling loop that we've left + # the keyboard + self.selected_key["y"] += 1 + return Keyboard.EXIT_TOP + + if self.selected_key["x"] >= len(self.keys[self.selected_key["y"]]): + # We're moving into the bottom line but there's no key directly below. + if self.selected_key["x"] - 1 == len(self.keys[self.selected_key["y"]]) - 1 and \ + self.keys[self.selected_key["y"]][-1].size == 2: + # The last, adjacent key in this row is a double. Go ahead and select it + self.selected_key["x"] = self.selected_key["x"] - 1 + else: + if Keyboard.WRAP_TOP in self.auto_wrap: + # This line is too short to land here + self.selected_key["y"] -= 1 + else: + # Undo selection change and notify controlling loop that we've left + # the keyboard + self.selected_key["y"] += 1 + return Keyboard.EXIT_TOP + + elif input == Keyboard.ENTER_LEFT: + # User has returned to the keyboard along the left edge + # Keep the last y position that was selected. + self.selected_key["x"] = 0 + + elif input == Keyboard.ENTER_RIGHT: + # User has returned to the keyboard along the right edge + # Keep the last y position that was selected. + self.selected_key["x"] = len(self.keys[self.selected_key["y"]]) - 1 + + elif input == Keyboard.ENTER_TOP: + # User has returned to the keyboard along the top edge + # Keep the last x position that was selected. + self.selected_key["y"] = 0 + + elif input == Keyboard.ENTER_BOTTOM: + # User has returned to the keyboard along the bottom edge + # Keep the last x position that was selected. + self.selected_key["y"] = len(self.keys) - 1 + if self.selected_key["x"] > len(self.keys[self.selected_key["y"]]) - 1: + if self.selected_key["x"] - 1 == len(self.keys[self.selected_key["y"]]) - 1 and \ + self.keys[self.selected_key["y"]][-1].size == 2: + # The last, adjacent key in this row is a double. Go ahead and select it + self.selected_key["x"] = self.selected_key["x"] - 1 + else: + # Can't enter here. Jump up a row + self.selected_key["y"] -= 1 + + # Render the newly self.selected_key letter + key = self.keys[self.selected_key["y"]][self.selected_key["x"]] + key.is_selected = True + key.render_key() + + return key.letter + + + def set_selected_key(self, selected_letter): + # De-select the current selected_key + self.keys[self.selected_key["y"]][self.selected_key["x"]].is_selected = False + + # Find the new selected_key + for i, row_keys in enumerate(self.keys): + for j, key in enumerate(row_keys): + if selected_letter and key.letter == selected_letter: + key.is_selected = True + self.selected_key["y"] = i + self.selected_key["x"] = j + return + raise Exception(f"""`selected_letter` "{selected_letter}" not found in keyboard""") + + + def set_selected_key_indices(self, x, y): + # De-select the current selected_key + self.keys[self.selected_key["y"]][self.selected_key["x"]].is_selected = False + + if y < len(self.keys): + self.selected_key["y"] = y + else: + self.selected_key["y"] = len(self.keys) - 1 + + if x < len(self.keys[self.selected_key["y"]]): + self.selected_key["x"] = x + else: + self.selected_key["x"] = len(self.keys[self.selected_key["y"]]) - 1 + + # Select the new selected_key + self.keys[self.selected_key["y"]][self.selected_key["x"]].is_selected = True + + + def get_selected_key(self): + return self.keys[self.selected_key["y"]][self.selected_key["x"]].letter + + +@dataclass +class TextEntryDisplay: + draw: any + rect: (int,int,int,int) + font: any + font_color: any + is_centered: bool = True + has_outline: bool = False + cur_text: str = " " + + + def render(self, cur_text=None): + """ Render the live text entry display """ + if cur_text: + self.cur_text = cur_text + if self.has_outline: + self.draw.rectangle(self.rect, fill="black", outline=self.font_color) + else: + self.draw.rectangle(self.rect, fill="black") + + cursor_block_width = 18 + cursor_block_height = 33 + + # Draw n-1 of the selected letters + tw, th = self.font.getsize(self.cur_text[:-1]) + if self.is_centered: + word_offset = self.rect[0] + int(self.rect[2] - self.rect[0] - tw - cursor_block_width)/2 + else: + word_offset = self.rect[0] + 3 + cursor_block_offset = word_offset + tw - 1 + if cursor_block_offset == self.rect[0]: + cursor_block_offset = self.rect[0] + 1 + + end_pos_x = cursor_block_offset + cursor_block_width + if end_pos_x >= self.rect[2]: + # Shift the display left + cursor_block_offset -= end_pos_x - self.rect[2] + 1 + word_offset -= end_pos_x - self.rect[2] + 1 + + self.draw.text((word_offset, self.rect[1] + 3), self.cur_text[:-1], fill=self.font_color, font=self.font) + + # Draw the highlighted cursor block + self.draw.rectangle((cursor_block_offset, self.rect[1] + 1, cursor_block_offset + cursor_block_width, self.rect[3] - 1), fill="#111") + self.draw.text((cursor_block_offset + 1, self.rect[1] + 3), self.cur_text[-1], fill=self.font_color, font=self.font) + diff --git a/src/seedsigner/helpers/path.py b/src/seedsigner/helpers/path.py index 7d80db67..05c7b41c 100644 --- a/src/seedsigner/helpers/path.py +++ b/src/seedsigner/helpers/path.py @@ -15,6 +15,7 @@ class Path: # Tools / Functions Routes GEN_LAST_WORD = 40 DICE_GEN_SEED = 41 + IMAGE_GEN_SEED = 45 SAVE_SEED = 42 PASSPHRASE_SEED = 43 DELETE_PASSPHRASE = 44 diff --git a/src/seedsigner/helpers/pivideostream.py b/src/seedsigner/helpers/pivideostream.py new file mode 100644 index 00000000..58284dda --- /dev/null +++ b/src/seedsigner/helpers/pivideostream.py @@ -0,0 +1,62 @@ +# import the necessary packages +from picamera.array import PiRGBArray +from picamera import PiCamera +from threading import Thread +import time + + +# Modified from: https://github.com/jrosebr1/imutils +class PiVideoStream: + def __init__(self, resolution=(320, 240), framerate=32, **kwargs): + # initialize the camera + self.camera = PiCamera(resolution=resolution, framerate=framerate, **kwargs) + + # initialize the stream + self.rawCapture = PiRGBArray(self.camera, size=resolution) + self.stream = self.camera.capture_continuous(self.rawCapture, + format="bgr", use_video_port=True) + + # initialize the frame and the variable used to indicate + # if the thread should be stopped + self.frame = None + self.stopped = False + + def start(self): + # start the thread to read frames from the video stream + t = Thread(target=self.update, args=()) + t.daemon = True + t.start() + return self + + def update(self): + # keep looping infinitely until the thread is stopped + for f in self.stream: + # grab the frame from the stream and clear the stream in + # preparation for the next frame + self.frame = f.array + self.rawCapture.truncate(0) + + # if the thread indicator variable is set, stop the thread + # and resource camera resources + if self.stopped: + print("PiVideoStream: closing everything") + self.stream.close() + self.rawCapture.close() + self.camera.close() + return + + def read(self): + # return the frame most recently read + return self.frame + + def stop(self): + # indicate that the thread should be stopped + self.stopped = True + + # while True: + # # Wait for camera to close before letting the thread get destroyed + # if not self.camera.closed: + # time.sleep(0.1) + # else: + # break + diff --git a/src/seedsigner/helpers/ur2/bytewords.py b/src/seedsigner/helpers/ur2/bytewords.py index 8c917898..b7e8432a 100644 --- a/src/seedsigner/helpers/ur2/bytewords.py +++ b/src/seedsigner/helpers/ur2/bytewords.py @@ -107,8 +107,8 @@ def decode(s, separator, word_len): body = buf[0:-4] body_checksum = buf[-4:] checksum = crc32_bytes(body) - if checksum != body_checksum: - raise ValueError('Invalid Bytewords.') + # if checksum != body_checksum: + # raise ValueError('Invalid Bytewords.') return body diff --git a/src/seedsigner/models/blue_wallet.py b/src/seedsigner/models/blue_wallet.py index 14d1ffa5..dad039b2 100644 --- a/src/seedsigner/models/blue_wallet.py +++ b/src/seedsigner/models/blue_wallet.py @@ -1,7 +1,12 @@ from . import Wallet -from seedsigner.helpers import QR +from seedsigner.helpers import Buttons, B, CameraProcess, CameraPoll, QR from seedsigner.helpers.bcur import (bcur_decode, cbor_decode, bc32decode, bc32encode, cbor_encode, bcur_encode) +from seedsigner.helpers.ur2.ur_decoder import URDecoder +from seedsigner.helpers.ur2.ur_encoder import UREncoder +from seedsigner.helpers.ur2.cbor_lite import CBOREncoder +from seedsigner.helpers.ur2.ur import UR +from seedsigner.views import View # External Dependencies from embit.bip39 import mnemonic_to_bytes @@ -21,16 +26,20 @@ class BlueWallet(Wallet): def __init__(self, current_network = "main", qr_density = Wallet.QRMEDIUM, policy = "PKWSH") -> None: Wallet.__init__(self, current_network, qr_density, policy) + def set_seed_phrase(self, seed_phrase, passphrase): + Wallet.set_seed_phrase(self, seed_phrase, passphrase) + self.ur_decoder = URDecoder() + def get_name(self) -> str: return "Blue Wallet" def parse_psbt(self, raw_psbt) -> bool: - raw_psbt = b2a_base64(cbor_decode(bc32decode(raw_psbt))) - - base64_psbt = a2b_base64(raw_psbt) - self.tx = psbt.PSBT.parse(base64_psbt) - - self._parse_psbt() + try: + base64_psbt = a2b_base64(raw_psbt) + self.tx = psbt.PSBT.parse(base64_psbt) + self._parse_psbt() + except Exception: + return False return True @@ -48,25 +57,92 @@ class BlueWallet(Wallet): raw_trimmed_signed_psbt = trimmed_psbt.serialize() - return bcur_encode(raw_trimmed_signed_psbt) + # convert to base64 + b64_psbt = b2a_base64(raw_trimmed_signed_psbt) + # somehow b2a ends with \n... + if b64_psbt[-1:] == b"\n": + b64_psbt = b64_psbt[:-1] + + return b64_psbt.decode('utf-8') + + def scan_animated_qr_pbst(self, controller) -> str: + self.controller = controller + self.buttons = controller.buttons + self.controller.menu_view.draw_modal(["Initializing Camera"]) # TODO: Move to Controller + # initialize camera + self.controller.to_camera_queue.put(["start"]) + # First get blocking, this way it's clear when the camera is ready for the end user + self.controller.from_camera_queue.get() + self.camera_loop_timer = CameraPoll(0.05, self.process_camera_data) + + input = self.buttons.wait_for([B.KEY_LEFT, B.KEY_RIGHT]) + if input in (B.KEY_LEFT, B.KEY_RIGHT): + self.camera_loop_timer.stop() + self.controller.to_camera_queue.put(["stop"]) + return "nodata" + elif input == B.OVERRIDE: + self.camera_loop_timer.stop() + self.controller.to_camera_queue.put(["stop"]) + if self.qr_data[0] == "invalid": + return "invalid" + elif self.qr_data[0] == "invalidpsbt": + return "invalid" + return b2a_base64(cbor_decode(self.ur_decoder.result.cbor)) + + def process_camera_data(self): + try: + data = self.controller.from_camera_queue.get(False) + except: + data = ["nodata"] + + if data[0] != "nodata": + if self.qr_total_frames == 0: + # get total frames if not set + self.qr_total_frames = type(self).total_frames_parse(data[0]) + if self.qr_total_frames == -1: + # when invalid, trigger override to display error + self.qr_data = ["invalid"] + self.buttons.trigger_override() # something went wrong, invalid QR + return + self.qr_data = ["empty"] + + # get data and percentage + decoder_work_check = self.ur_decoder.receive_part(data[0]) + if decoder_work_check == False: + self.qr_data = ["invalidpsbt"] + self.buttons.trigger_override() # something went wrong, invalid QR + return + self.percentage_complete = self.ur_decoder.estimated_percent_complete() + + # checking if all frames has been captured, exit camera processing + if self.capture_complete(): + self.buttons.trigger_override() + + # if all frames have not all been captured, display progress to screen/display + if not self.capture_complete() and self.scan_display_working == 0: + self.scan_display_working = 1 + View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0) + tw, th = View.draw.textsize("Collecting QR Codes:", font=View.IMPACT25) + View.draw.text(((240 - tw) / 2, 15), "Collecting QR Codes:", fill=View.color, font=View.IMPACT25) + tw, th = View.draw.textsize(str(round(self.percentage_complete * 100)) + "% Complete", font=View.IMPACT22) + View.draw.text(((240 - tw) / 2, 125), str(round(self.percentage_complete * 100)) + "% Complete", fill=View.color, font=View.IMPACT22) + View.DispShowImage() + self.scan_display_working = 0 + + elif self.scan_started_ind == 0: + self.scan_started_ind = 1 + self.controller.menu_view.draw_modal(["Scan Animated QR"], "", "Right to Exit") def total_frames_parse(data) -> int: - if re.search("^UR\:BYTES\/(\d+)OF(\d+)", data, re.IGNORECASE) != None: - return int(re.search("^UR\:BYTES\/(\d+)OF(\d+)", data, re.IGNORECASE).group(2)) + if re.search("^UR\:CRYPTO-PSBT\/(\d+)\-(\d+)\/", data, re.IGNORECASE) != None: + return 10 # valid + elif re.search("^UR\:CRYPTO-PSBT", data, re.IGNORECASE) != None: + return 1 # valid but only 1 segment else: - return -1 - - def current_frame_parse(data) -> int: - if re.search("^UR\:BYTES\/(\d+)OF(\d+)", data, re.IGNORECASE) != None: - return int(re.search("^UR\:BYTES\/(\d+)OF(\d+)", data, re.IGNORECASE).group(1)) - else: - return -1 - - def data_parse(data) -> str: - return data.split("/")[-1].strip() + return -1 #invalid def capture_complete(self) -> bool: - if "empty" not in self.qr_data: + if self.ur_decoder.is_complete(): return True else: return False @@ -91,44 +167,36 @@ class BlueWallet(Wallet): def make_signing_qr_codes(self, data, callback = None) -> []: qr = QR() - - qrdata = data[0].upper() - qrhash = data[1].upper() - - cnt = 0 images = [] - start = 0 - stop = self.qrsize - qr_cnt = ((len(qrdata)-1) // self.qrsize) + 1 + cnt = 0 - if qr_cnt == 1: - part = "UR:BYTES/" + qrhash + "/" + qrdata[start:stop] + cbor_encoder = CBOREncoder() + cbor_encoder.encodeBytes(a2b_base64(data)) + + qr_ur_bytes = UR("crypto-psbt", cbor_encoder.get_bytes()) + ur2_encode = UREncoder(qr_ur_bytes,self.qrsize,0) + qr_cnt = ur2_encode.fountain_encoder.seq_len() + + while not ur2_encode.is_complete(): + + part = ur2_encode.next_part().upper() images.append(qr.qrimage(part)) print(part) - - while cnt < qr_cnt and qr_cnt != 1: - part = "UR:BYTES/" + str(cnt+1) + "OF" + str(qr_cnt) + "/" + qrhash + "/" + qrdata[start:stop] - images.append(qr.qrimage(part)) - print(part) - start = start + self.qrsize - stop = stop + self.qrsize - if stop > len(qrdata): - stop = len(qrdata) cnt += 1 if callback != None: - callback((cnt * 100.0) / qr_cnt) + callback((cnt * 100.0) / qr_cnt) return images def set_qr_density(self, density): self.cur_qr_density = density if density == Wallet.QRLOW: - self.qrsize = 20 + self.qrsize = 50 elif density == Wallet.QRMEDIUM: - self.qrsize = 100 + self.qrsize = 70 elif density == Wallet.QRHIGH: - self.qrsize = 140 + self.qrsize = 120 ### ### Internal Wallet Transactions OVERRIDE diff --git a/src/seedsigner/resources/fonts/RobotoCondensed-Bold.ttf b/src/seedsigner/resources/fonts/RobotoCondensed-Bold.ttf new file mode 100644 index 00000000..7fe31289 Binary files /dev/null and b/src/seedsigner/resources/fonts/RobotoCondensed-Bold.ttf differ diff --git a/src/seedsigner/resources/fonts/RobotoCondensed-Light.ttf b/src/seedsigner/resources/fonts/RobotoCondensed-Light.ttf new file mode 100644 index 00000000..43dd8f42 Binary files /dev/null and b/src/seedsigner/resources/fonts/RobotoCondensed-Light.ttf differ diff --git a/src/seedsigner/resources/fonts/RobotoCondensed-Regular.ttf b/src/seedsigner/resources/fonts/RobotoCondensed-Regular.ttf new file mode 100644 index 00000000..62dd61e5 Binary files /dev/null and b/src/seedsigner/resources/fonts/RobotoCondensed-Regular.ttf differ diff --git a/src/seedsigner/views/io_test_view.py b/src/seedsigner/views/io_test_view.py index 2600798f..f296d41a 100644 --- a/src/seedsigner/views/io_test_view.py +++ b/src/seedsigner/views/io_test_view.py @@ -9,8 +9,8 @@ from seedsigner.helpers import B, CameraPoll class IOTestView(View): - def __init__(self, controller) -> None: - View.__init__(self, controller) + def __init__(self) -> None: + View.__init__(self) self.camera_loop_timer = None self.redraw = False self.redraw_complete = False diff --git a/src/seedsigner/views/menu_view.py b/src/seedsigner/views/menu_view.py index 09aa40ca..c5b9b974 100644 --- a/src/seedsigner/views/menu_view.py +++ b/src/seedsigner/views/menu_view.py @@ -7,10 +7,11 @@ from seedsigner.models import SeedStorage import time import re + class MenuView(View): - def __init__(self, controller) -> None: - View.__init__(self, controller) + def __init__(self) -> None: + View.__init__(self) self.menu_lines = [] self.selected_menu_num = 1 @@ -38,7 +39,7 @@ class MenuView(View): # Wait for Button Input (specifically menu selection/press) while True: if ret_val == 0: - input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS]) + input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS]) else: return ret_val if input == B.KEY_UP: @@ -70,13 +71,13 @@ class MenuView(View): else: seed_storage_line = "View Seeds (temp)" - lines = ["... [ Return to Main ]", "Input a Seed", "Add/Remove Passphrase", "Generate an xPub", "Generate Work 12/24", "Generate a Seed with Dice"] + lines = ["... [ Return to Main ]", "Input a Seed", "Add/Remove Passphrase", "Generate an xPub", "Generate Word 12/24", "Generate a Seed with Dice", "Generate a Seed with Image"] self.draw_menu(lines, 1) input = 0 # Wait for Button Input (specifically menu selection/press) while True: - input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS]) + input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS]) if input == B.KEY_UP: self.menu_up() elif input == B.KEY_DOWN: @@ -94,6 +95,8 @@ class MenuView(View): return Path.GEN_LAST_WORD elif self.selected_menu_num == 6: return Path.DICE_GEN_SEED + elif self.selected_menu_num == 7: + return Path.IMAGE_GEN_SEED ### Signing Tools Menu @@ -104,7 +107,7 @@ class MenuView(View): # Wait for Button Input (specifically menu selection/press) while True: - input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS]) + input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS]) if input == B.KEY_UP: self.menu_up() elif input == B.KEY_DOWN: @@ -135,7 +138,7 @@ class MenuView(View): # Wait for Button Input (specifically menu selection/press) while True: - input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS]) + input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS]) if input == B.KEY_UP: self.menu_up() elif input == B.KEY_DOWN: @@ -166,7 +169,7 @@ class MenuView(View): self.draw_menu(lines, 1, title, bottom, True) while True: - input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS]) + input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS]) if input == B.KEY_UP: self.menu_up(title, bottom) elif input == B.KEY_DOWN: @@ -182,7 +185,7 @@ class MenuView(View): # Wait for Button Input (specifically menu selection/press) while True: - input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS]) + input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS]) if input == B.KEY_UP: self.menu_up() elif input == B.KEY_DOWN: @@ -255,7 +258,7 @@ class MenuView(View): # Wait for Button Input (specifically menu selection/press) while True: - input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS]) + input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS]) if input == B.KEY_UP: self.menu_up() elif input == B.KEY_DOWN: @@ -272,7 +275,7 @@ class MenuView(View): ### ### Generic Draw Menu Method - + # TODO: Optimize updates by just redrawing the no-longer highlighted line and the newly highlighted line def draw_menu(self, lines, selected_menu_num = 1, title = None, bottom = None, force_redraw = False) -> None: if title == None: t = "SeedSigner v" + self.controller.VERSION diff --git a/src/seedsigner/views/seed_tools_view.py b/src/seedsigner/views/seed_tools_view.py index 4cc1fced..33fba4b4 100644 --- a/src/seedsigner/views/seed_tools_view.py +++ b/src/seedsigner/views/seed_tools_view.py @@ -2,12 +2,16 @@ from embit import bip39 from embit.bip39 import mnemonic_to_bytes, mnemonic_from_bytes from PIL import ImageDraw, Image +from PIL.ImageOps import autocontrast +import hashlib import math import time +import traceback # Internal file class dependencies from . import View -from seedsigner.helpers import B, QR, CameraPoll +from seedsigner.helpers import B, QR, CameraPoll, Keyboard, TextEntryDisplay + class SeedToolsView(View): @@ -16,16 +20,18 @@ class SeedToolsView(View): ALPHABET = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"] - def __init__(self, controller) -> None: - View.__init__(self, controller) + def __init__(self) -> None: + View.__init__(self) # Gather words and seed display information self.words = [] self.letters = [] self.possible_alphabet = [] self.possible_words = [] + self.selected_possible_words_index = 0 self.seed_length = 12 # Default to 12, Valid values are 11, 12, 23 and 24 self.seed_qr_image = None + self.seed_entropy_image = None # Dice information self.roll_number = 1 @@ -80,6 +86,566 @@ class SeedToolsView(View): self.draw_gather_words() + + def display_manual_seed_entry(self, num_words): + self.seed_length = num_words + self.reset() + + cur_word = 1 + while len(self.words) < self.seed_length: + initial_letters = ["a"] + if len(self.words) >= cur_word: + initial_letters = list(self.words[cur_word - 1]) # zero-indexed + + ret_val = self.draw_seed_word_keyboard_entry(num_word=cur_word, initial_letters=initial_letters) + + if ret_val == Keyboard.KEY_PREVIOUS_PAGE: + # Reload previous word + cur_word -= 1 + if cur_word == 0: + # Exit this UI + return [] + else: + # We've backed `cur_word` up, so re-enter loop + continue + + if len(self.words) < cur_word: + self.words.append(ret_val.strip()) + else: + self.words[cur_word - 1] = ret_val.strip() + cur_word += 1 + + return self.words + + + def draw_seed_word_keyboard_entry(self, num_word, initial_letters=["a"]): + def render_possible_matches(highlight_word=None): + """ Internal helper method to render the KEY 1, 2, 3 word candidates. + (has access to all vars in the parent's context) + """ + # Clear the right panel + View.draw.rectangle((keyboard_width, text_entry_display_height, View.canvas_width, View.canvas_height), fill="black") + + if not self.possible_words: + return + + row_height = 26 + x = keyboard_width + 10 + y = text_entry_display_height - int(row_height / 2) + + highlighted_row = 3 + num_possible_rows = 11 + + list_starting_index = self.selected_possible_words_index - highlighted_row + + word_font = View.ROBOTOCONDENSED_REGULAR_22 + for row, i in enumerate(range(list_starting_index, list_starting_index + num_possible_rows)): + if i < 0: + # We're near the top of the list, not enough items to fill above the highlighted row + continue + if row == highlighted_row: + # Leave the highlighted row to be rendered below + continue + + if len(self.possible_words) <= i: + break + + View.draw.text((x, y + row * row_height), self.possible_words[i], fill=View.color, font=word_font) + + # Render the SELECT outline + if highlight_word: + fill_color = View.color + font_color = "black" + else: + fill_color = "#111" + font_color = View.color + View.draw.rounded_rectangle((keyboard_width + 4, y + (3 * row_height) - 2, 250, y + (4 * row_height) + 2), outline=View.color, fill=fill_color, radius=5, width=1) + + if self.possible_words: + word_font = View.ROBOTOCONDENSED_BOLD_24 + View.draw.text((x, y + 3 * row_height), self.possible_words[self.selected_possible_words_index], fill=font_color, font=word_font) + + render_possible_matches_arrows() + + + def render_possible_matches_arrows(): + # Render the up/down arrow buttons for KEY1 and KEY3 + row_height = 26 + arrow_button_width = 25 + arrow_padding = 5 + key_x = View.canvas_width - arrow_button_width + key_y = text_entry_display_height - int(row_height / 2) + int(0.75 * row_height) + background_color = "#111" + arrow_color = View.color + if arrow_up_is_active: + background_color = View.color + arrow_color = "#111" + View.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1) + View.draw.polygon( + [(key_x + int(arrow_button_width)/2 + 1, key_y + arrow_padding), # centered top point + (View.canvas_width - arrow_padding + 1, key_y + row_height - arrow_padding), # bottom right point + (key_x + arrow_padding + 1, key_y + row_height - arrow_padding)], # bottom left point + fill=arrow_color + ) + + background_color = "#111" + arrow_color = View.color + if arrow_down_is_active: + background_color = View.color + arrow_color = "#111" + key_y = text_entry_display_height - int(row_height / 2) + int(5.25 * row_height) + View.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1) + View.draw.polygon( + [(key_x + int(arrow_button_width)/2 + 1, key_y + row_height - arrow_padding), # bottom centered point + (View.canvas_width - arrow_padding + 1, key_y + arrow_padding), # right top point + (key_x + arrow_padding + 1, key_y + arrow_padding)], # left top point + fill=arrow_color + ) + + + # Clear the screen + View.draw.rectangle((0,0, View.canvas_width,View.canvas_height), fill="black") + + self.render_previous_button() + previous_button_is_active = False + arrow_up_is_active = False + arrow_down_is_active = False + + # Have to ensure that we don't carry any effects from a previous run + # TODO: This shouldn't be a member var + self.possible_alphabet = "abcdefghijklmnopqrstuvwxyz" + + # Set up the keyboard params + keyboard_width = 120 + text_entry_display_height = 39 + + # TODO: support other BIP39 languages/charsets + keyboard = Keyboard(View.draw, + charset=self.possible_alphabet, + rows=5, + cols=6, + rect=(0,text_entry_display_height + 1, keyboard_width,240), + auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT]) + + # Render the top text entry display + self.letters = initial_letters + text_entry_display = TextEntryDisplay( + View.draw, + rect=(self.previous_button_width,0, View.canvas_width,text_entry_display_height), + font=View.ROBOTOCONDENSED_BOLD_26, + font_color=View.color, + cur_text=f"{num_word}: " + "".join(self.letters) + ) + text_entry_display.render() + + # Initialize the current matches + self.possible_words = [] + if len(self.letters) > 1: + self.letters.append(" ") # "Lock in" the last letter as if KEY_PRESS + self.calc_possible_alphabet() + keyboard.update_active_keys(active_keys=self.possible_alphabet) + keyboard.set_selected_key(selected_letter=self.letters[-2]) + else: + keyboard.set_selected_key(selected_letter=self.letters[-1]) + keyboard.render_keys() + render_possible_matches() + + View.DispShowImage() + + # Start the interactive update loop + while True: + input = View.buttons.wait_for( + [B.KEY_UP, B.KEY_DOWN, B.KEY_RIGHT, B.KEY_LEFT, B.KEY_PRESS, B.KEY1, B.KEY2, B.KEY3], + check_release=True, + release_keys=[B.KEY_PRESS, B.KEY2] + ) + + if previous_button_is_active: + if input == B.KEY_PRESS: + # User clicked the "back" arrow + return Keyboard.KEY_PREVIOUS_PAGE + elif input == B.KEY_UP: + input = Keyboard.ENTER_BOTTOM + # Re-render it without the highlight + previous_button_is_active = False + self.render_previous_button() + + elif input == B.KEY_DOWN: + input = Keyboard.ENTER_TOP + # Re-render it without the highlight + previous_button_is_active = False + self.render_previous_button() + + elif input in [B.KEY_RIGHT, B.KEY_LEFT]: + # no action in this context + continue + + ret_val = keyboard.update_from_input(input) + + if ret_val in Keyboard.EXIT_DIRECTIONS: + self.render_previous_button(highlight=True) + previous_button_is_active = True + + elif ret_val in Keyboard.ADDITIONAL_KEYS: + if input == B.KEY_PRESS and ret_val == Keyboard.KEY_BACKSPACE["letter"]: + self.letters = self.letters[:-2] + self.letters.append(" ") + + # Reactivate keys after deleting last letter + self.calc_possible_alphabet() + keyboard.update_active_keys(active_keys=self.possible_alphabet) + keyboard.render_keys() + + # Update the right-hand possible matches area + render_possible_matches() + + elif ret_val == Keyboard.KEY_BACKSPACE["letter"]: + # We're just hovering over DEL but haven't clicked. Show blank (" ") + # in the live text entry display at the top. + self.letters = self.letters[:-1] + self.letters.append(" ") + + # Has the user made a final selection of a candidate word? + final_selection = None + if input == B.KEY1 and self.possible_words: + # Scroll the list up + self.selected_possible_words_index -= 1 + if self.selected_possible_words_index < 0: + self.selected_possible_words_index = 0 + + if not arrow_up_is_active: + # Flash the up arrow as selected + arrow_up_is_active = True + + # Update the right-hand possible matches area + render_possible_matches() + + elif input == B.KEY2: + if self.possible_words: + final_selection = self.possible_words[self.selected_possible_words_index] + + elif input == B.KEY3 and self.possible_words: + # Scroll the list down + self.selected_possible_words_index += 1 + if self.selected_possible_words_index >= len(self.possible_words): + self.selected_possible_words_index = len(self.possible_words) - 1 + + if not arrow_down_is_active: + # Flash the down arrow as selected + arrow_down_is_active = True + + # Update the right-hand possible matches area + render_possible_matches() + + if input is not B.KEY1 and arrow_up_is_active: + # Deactivate the arrow and redraw + arrow_up_is_active = False + render_possible_matches_arrows() + + if input is not B.KEY3 and arrow_down_is_active: + # Deactivate the arrow and redraw + arrow_down_is_active = False + render_possible_matches_arrows() + + if final_selection: + # Animate the selection storage, then return the word to the caller + self.letters = list(final_selection + " ") + render_possible_matches(highlight_word=final_selection) + text_entry_display.render(f"{num_word}: " + "".join(self.letters)) + View.DispShowImage() + + return final_selection + + elif input == B.KEY_PRESS and ret_val in self.possible_alphabet: + # User has locked in the current letter + if self.letters[-1] != " ": + # We'll save that locked in letter next but for now update the + # live text entry display with blank (" ") so that we don't try + # to autocalc matches against a second copy of the letter they + # just selected. e.g. They KEY_PRESS on "s" to build "mus". If + # we advance the live block cursor AND display "s" in it, the + # current word would then be "muss" with no matches. If "mus" + # can get us to our match, we don't want it to disappear right + # as we KEY_PRESS. + self.letters.append(" ") + else: + # clicked same letter twice in a row. Because of the above, an + # immediate second click of the same letter would lock in "ap " + # (note the space) instead of "app". So we replace that trailing + # space with the correct repeated letter and then, as above, + # append a trailing blank. + self.letters = self.letters[:-1] + self.letters.append(ret_val) + self.letters.append(" ") + + # Recalc and deactivate keys after advancing + self.calc_possible_alphabet() + keyboard.update_active_keys(active_keys=self.possible_alphabet) + + # Update the right-hand possible matches area + render_possible_matches() + + if len(self.possible_alphabet) == 1: + # If there's only one possible letter left, select it + keyboard.set_selected_key(self.possible_alphabet[0]) + + keyboard.render_keys() + + elif input in [B.KEY_RIGHT, B.KEY_LEFT, B.KEY_UP, B.KEY_DOWN] and ret_val in self.possible_alphabet: + # Live joystick movement; haven't locked this new letter in yet. + # Replace the last letter w/the currently selected one. But don't + # call `calc_possible_alphabet()` because we want to still be able + # to freely float to a different letter; only update the active + # keyboard keys when a selection has been locked in (KEY_PRESS) or + # removed ("del"). + self.letters = self.letters[:-1] + self.letters.append(ret_val) + self.calc_possible_words() # live update our matches as we move + + # Update the right-hand possible matches area + render_possible_matches() + + # Render the text entry display and cursor block + text_entry_display.render(f"{num_word}: " + "".join(self.letters)) + + View.DispShowImage() + + + def draw_passphrase_keyboard_entry(self, existing_passphrase = ""): + def render_right_panel(button1_text="ABC", button2_text="!@#"): + # Render the up/down arrow buttons for KEY1 and KEY3 + row_height = 28 + right_button_left_margin = 10 + right_button_width = right_panel_buttons_width - right_button_left_margin + font_padding_right = 2 + font_padding_top = 1 + key_x = View.canvas_width - right_button_width + key_y = int(View.canvas_height - row_height) / 2 - 1 - 60 + + background_color = "#111" + font_color = View.color + font = View.ROBOTOCONDENSED_BOLD_24 + tw, th = font.getsize(button1_text) + if button1_is_active: + background_color = View.color + font_color = "#111" + View.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1) + View.draw.text((View.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button1_text, fill=font_color) + + background_color = "#111" + font_color = View.color + tw, th = font.getsize(button2_text) + if button2_is_active: + background_color = View.color + font_color = "#111" + key_y = int(View.canvas_height - row_height) / 2 - 1 + View.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1) + View.draw.text((View.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button2_text, fill=font_color) + + background_color = "#111" + font_color = View.color + button3_text = "Save" + tw, th = font.getsize(button3_text) + if button3_is_active: + background_color = View.color + font_color = "#111" + key_y = int(View.canvas_height - row_height) / 2 - 1 + 60 + View.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1) + View.draw.text((View.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button3_text, fill=font_color) + + # Clear the screen + View.draw.rectangle((0,0, View.canvas_width,View.canvas_height), fill="black") + + self.render_previous_button() + previous_button_is_active = False + + # Have to ensure that we don't carry any effects from a previous run + # TODO: This shouldn't be a member var + if existing_passphrase: + self.passphrase = existing_passphrase + else: + self.passphrase = " " + + # Set up the keyboard params + right_panel_buttons_width = 60 + + # render top title banner + font = View.ROBOTOCONDENSED_REGULAR_20 + title = "Enter Passphrase" + title_top_padding = 0 + title_bottom_padding = 10 + tw, th = font.getsize(title) + View.draw.text((int(View.canvas_width - tw) / 2, title_top_padding), text=title, font=font, fill=View.color) + title_height = th + title_top_padding + title_bottom_padding + + # Render the live text entry display + font = View.ROBOTOCONDENSED_REGULAR_28 + tw, th = font.getsize("!\"#$%&'()*+,=./;:<>?@[]|-_`~ ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 1234567890") # All possible chars for max range + text_entry_side_padding = 0 + text_entry_top_padding = 1 + text_entry_bottom_padding = 10 + text_entry_top_y = title_height + text_entry_top_padding + text_entry_bottom_y = text_entry_top_y + 3 + th + 3 + text_entry_display = TextEntryDisplay( + View.draw, + rect=(text_entry_side_padding,text_entry_top_y, View.canvas_width - right_panel_buttons_width - 1, text_entry_bottom_y), + font=font, + font_color=View.color, + is_centered=False, + has_outline=True, + cur_text=''.join(self.passphrase) + ) + text_entry_display.render() + + keyboard_start_y = text_entry_bottom_y + text_entry_bottom_padding + keyboard_abc = Keyboard(View.draw, + charset="1234567890" + "".join(SeedToolsView.ALPHABET), + rows=4, + cols=10, + rect=(0, keyboard_start_y, View.canvas_width - right_panel_buttons_width, View.canvas_height), + auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT]) + keyboard_ABC = Keyboard(View.draw, + charset="1234567890" + "".join(SeedToolsView.ALPHABET).upper(), + rows=4, + cols=10, + rect=(0, keyboard_start_y, View.canvas_width - right_panel_buttons_width, View.canvas_height), + auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT], + render_now=False) + keyboard_symbol = Keyboard(View.draw, + charset="1234567890" + "!\"#$%&'()*+,=./;:<>?@[]|-_`~", + rows=4, + cols=10, + rect=(0, keyboard_start_y, View.canvas_width - right_panel_buttons_width, View.canvas_height), + auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT], + render_now=False) + + button1_is_active = False + button2_is_active = False + button3_is_active = False + KEYBOARD__LOWERCASE = 0 + KEYBOARD__UPPERCASE = 1 + KEYBOARD__SYMBOL = 2 + cur_keyboard_type = KEYBOARD__LOWERCASE + cur_keyboard = keyboard_abc + render_right_panel() + + View.DispShowImage() + + # Start the interactive update loop + while True: + input = View.buttons.wait_for( + [B.KEY_UP, B.KEY_DOWN, B.KEY_RIGHT, B.KEY_LEFT, B.KEY_PRESS, B.KEY1, B.KEY2, B.KEY3], + check_release=True, + release_keys=[B.KEY_PRESS, B.KEY1, B.KEY2, B.KEY3] + ) + + keyboard_swap = False + + # Check our two possible exit conditions + if input == B.KEY3: + # Save! + if self.passphrase != "" and self.passphrase != " ": + return self.passphrase.strip() + + elif input == B.KEY_PRESS and previous_button_is_active: + # Prev button clicked; return empty string to signal cancel. + return "" + + # Check for keyboard swaps + if input == B.KEY1: + if cur_keyboard_type == KEYBOARD__LOWERCASE: + keyboard_ABC.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) + cur_keyboard_type = KEYBOARD__UPPERCASE + cur_keyboard = keyboard_ABC + render_right_panel(button1_text="abc", button2_text="!@#") + else: + keyboard_abc.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) + cur_keyboard_type = KEYBOARD__LOWERCASE + cur_keyboard = keyboard_abc + render_right_panel(button1_text="ABC", button2_text="!@#") + cur_keyboard.render_keys() + keyboard_swap = True + ret_val = cur_keyboard.get_selected_key() + + elif input == B.KEY2: + if cur_keyboard_type in [KEYBOARD__LOWERCASE, KEYBOARD__UPPERCASE]: + keyboard_symbol.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"]) + cur_keyboard_type = KEYBOARD__SYMBOL + cur_keyboard = keyboard_symbol + cur_keyboard.render_keys() + render_right_panel(button1_text="abc", button2_text="") + keyboard_swap = True + ret_val = cur_keyboard.get_selected_key() + + else: + # Process normal input + if input in [B.KEY_UP, B.KEY_DOWN] and previous_button_is_active: + # We're navigating off the previous button + previous_button_is_active = False + self.render_previous_button(highlight=False) + + # Override the actual input w/an ENTER signal for the Keyboard + if input == B.KEY_DOWN: + input = Keyboard.ENTER_TOP + else: + input = Keyboard.ENTER_BOTTOM + elif input in [B.KEY_LEFT, B.KEY_RIGHT] and previous_button_is_active: + # ignore + continue + + + ret_val = cur_keyboard.update_from_input(input) + + # Now process the result from the keyboard + if ret_val in Keyboard.EXIT_DIRECTIONS: + self.render_previous_button(highlight=True) + previous_button_is_active = True + + elif ret_val in Keyboard.ADDITIONAL_KEYS: + if input == B.KEY_PRESS and ret_val == Keyboard.KEY_BACKSPACE["letter"]: + self.passphrase = self.passphrase[:-2] + self.passphrase += " " + + elif ret_val == Keyboard.KEY_BACKSPACE["letter"]: + # We're just hovering over DEL but haven't clicked. Show blank (" ") + # in the live text entry display at the top. + self.passphrase = self.passphrase[:-1] + self.passphrase += " " + + elif input == B.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS: + # User has locked in the current letter + if self.passphrase[-1] != " ": + # We'll save that locked in letter next but for now update the + # live text entry display with blank (" ") so that we don't try + # to autocalc matches against a second copy of the letter they + # just selected. e.g. They KEY_PRESS on "s" to build "mus". If + # we advance the live block cursor AND display "s" in it, the + # current word would then be "muss" with no matches. If "mus" + # can get us to our match, we don't want it to disappear right + # as we KEY_PRESS. + self.passphrase += " " + else: + # clicked same letter twice in a row. Because of the above, an + # immediate second click of the same letter would lock in "ap " + # (note the space) instead of "app". So we replace that trailing + # space with the correct repeated letter and then, as above, + # append a trailing blank. + self.passphrase = self.passphrase[:-1] + self.passphrase += ret_val + self.passphrase += " " + + elif input in [B.KEY_RIGHT, B.KEY_LEFT, B.KEY_UP, B.KEY_DOWN] or keyboard_swap: + # Live joystick movement; haven't locked this new letter in yet. + # Leave current spot blank for now. Only update the active keyboard keys + # when a selection has been locked in (KEY_PRESS) or removed ("del"). + pass + + # Render the text entry display and cursor block + text_entry_display.render(self.passphrase) + + View.DispShowImage() + + def gather_words_up(self): View.draw.polygon([(8 + ((len(self.letters)-1)*30), 85) , (14 + ((len(self.letters)-1)*30), 69) , (20 + ((len(self.letters)-1)*30), 85 )], outline=View.color, fill=View.color) View.DispShowImage() @@ -193,10 +759,10 @@ class SeedToolsView(View): return + ### ### Display Gather PassPhrase Screen ### - def display_gather_passphrase_screen(self, existing_passphrase = "") -> str: self.reset() self.pass_letter = "a" @@ -828,7 +1394,6 @@ class SeedToolsView(View): cur_y = next_y - def parse_seed_qr_data(self): try: data = self.controller.from_camera_queue.get(False) @@ -874,10 +1439,109 @@ class SeedToolsView(View): self.buttons.trigger_override(True) return self.words[:] + + def parse_seed_image_data(self): + try: + data = self.controller.from_camera_queue.get(block=False) + print(f"got queue data: {data}") + except: + return + + self.seed_entropy_image = data[0] + print("exiting parse_seed_image_data") + + + def seed_phrase_from_camera_image(self): + reshoot = False + + self.seed_entropy_image = None + self.controller.menu_view.draw_modal(["Initializing Camera..."]) # TODO: Move to Controller + # initialize camera + self.controller.to_camera_queue.put(["single_frame"]) + # First get blocking, this way it's clear when the camera is ready for the end user + print("Waiting for 'ready' from camera") + while True: + try: + msg = self.controller.from_camera_queue.get(block=False) + print(f"msg received: {msg}") + + # check 'ready', sometimes queue contains nodata or data that needs to be cleared + is_ready = False + for m in msg: + if m == "ready": + is_ready = True + if is_ready: + break + except: + time.sleep(0.1) + + self.controller.menu_view.draw_modal(["Aim camera", "click joystick"], title="Capture Image as Seed", bottom="Left to Cancel") # TODO: Move to Controller + + input = self.buttons.wait_for([B.KEY_LEFT, B.KEY_PRESS]) + if input == B.KEY_LEFT: + self.words = [] + self.controller.to_camera_queue.put(["stop"]) + + elif input == B.KEY_PRESS: + try: + print("KEY_PRESS") + self.controller.menu_view.draw_modal(["Auto-adjusting", "exposure..."], title="Capture Image as Seed") # TODO: Move to Controller + self.controller.to_camera_queue.put(["click"]) + self.camera_loop_timer = CameraPoll(0.05, self.parse_seed_image_data) + + # Wait for the camera process to complete + while self.seed_entropy_image is None: + time.sleep(0.2) + except Exception as e: + traceback.print_exc() + finally: + self.controller.to_camera_queue.put(["stop"]) + self.camera_loop_timer.stop() + + # Prep a copy of the image for display. The actual image data is 720x480 + # Present just a center crop to fit the screen and to keep some of the + # data hidden. + display_version = autocontrast( + self.seed_entropy_image, + cutoff=2 + ).rotate( + 90 + ).crop( + (120, 0, 600, 480) + ).resize( + (View.canvas_width, View.canvas_height), Image.BICUBIC + ) + + View.DispShowImageWithText( + display_version, + text=" < reshoot | accept > ", + font=View.ROBOTOCONDENSED_REGULAR_22, + text_color=View.color, + text_background=(0,0,0,225) + ) + + input = self.buttons.wait_for([B.KEY_LEFT, B.KEY_RIGHT]) + if input == B.KEY_LEFT: + reshoot = True + + else: + # TODO: Pull this out into its own method so we can write tests against it + hash = hashlib.sha256(self.seed_entropy_image.tobytes()) + badseedphrase_str = mnemonic_from_bytes(hash.digest()) + badseedphrase_list = badseedphrase_str.split() + badseedphrase_list.pop(-1) + calclastwordphrasestr = " ".join(badseedphrase_list) + " abandon" + goodphrasebytes = mnemonic_to_bytes(calclastwordphrasestr, ignore_checksum=True) + goodseedphrasestr = mnemonic_from_bytes(goodphrasebytes) + self.words = goodseedphrasestr.split() + + # self.buttons.trigger_override(True) + return (reshoot, self.words) + + ### ### Utility Methods ### - def reset(self): self.words.clear() self.possible_alphabet = SeedToolsView.ALPHABET[:] @@ -888,18 +1552,25 @@ class SeedToolsView(View): return + def calc_possible_alphabet(self, new_letter = False): - if (len(self.letters) > 1 and new_letter == False) or (len(self.letters) > 0 and new_letter == True): + if (self.letters and len(self.letters) > 1 and new_letter == False) or (len(self.letters) > 0 and new_letter == True): search_letters = self.letters[:] if new_letter == False: search_letters.pop() - possible_words = [i for i in SeedToolsView.SEEDWORDS if i.startswith("".join(search_letters))] + self.calc_possible_words() letter_num = len(search_letters) possible_letters = [] - for word in possible_words: + for word in self.possible_words: if len(word)-1 >= letter_num: possible_letters.append(word[letter_num]) # remove duplicates and keep order self.possible_alphabet = list(dict.fromkeys(possible_letters))[:] else: self.possible_alphabet = SeedToolsView.ALPHABET[:] + self.possible_words = [] + + + def calc_possible_words(self): + self.possible_words = [i for i in SeedToolsView.SEEDWORDS if i.startswith("".join(self.letters).strip())] + self.selected_possible_words_index = 0 diff --git a/src/seedsigner/views/settings_tools_view.py b/src/seedsigner/views/settings_tools_view.py index 57c27c77..9aa9e007 100644 --- a/src/seedsigner/views/settings_tools_view.py +++ b/src/seedsigner/views/settings_tools_view.py @@ -3,10 +3,11 @@ from . import View from seedsigner.helpers import B, QR + class SettingsToolsView(View): - def __init__(self, controller) -> None: - View.__init__(self, controller) + def __init__(self) -> None: + View.__init__(self) self.qr = QR() self.donate_image = None diff --git a/src/seedsigner/views/signing_tools_view.py b/src/seedsigner/views/signing_tools_view.py index 83c9a924..08eda71e 100644 --- a/src/seedsigner/views/signing_tools_view.py +++ b/src/seedsigner/views/signing_tools_view.py @@ -8,8 +8,8 @@ import time class SigningToolsView(View): - def __init__(self, controller, seed_storage) -> None: - View.__init__(self, controller) + def __init__(self, seed_storage) -> None: + View.__init__(self) self.seed_storage = seed_storage ### diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 1ed108d6..f11e4f30 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -1,9 +1,12 @@ # External Dependencies from PIL import Image, ImageDraw, ImageFont +import os +import pathlib import spidev as SPI +import time from multiprocessing import Queue +from seedsigner.helpers import B, ST7789 -from seedsigner.helpers import ST7789 ### Generic View Class to Instatiate Display ### Static Class variables are used for display @@ -31,11 +34,29 @@ class View: COURIERNEW30 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/courbd.ttf', 30) COURIERNEW20 = ImageFont.truetype('/usr/share/fonts/truetype/msttcorefonts/courbd.ttf', 20) + font_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "..", "resources", "fonts") + + ROBOTOCONDENSED_BOLD_16 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 16) + ROBOTOCONDENSED_BOLD_18 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 18) + ROBOTOCONDENSED_BOLD_20 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 20) + ROBOTOCONDENSED_BOLD_22 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 20) + ROBOTOCONDENSED_BOLD_24 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 22) + ROBOTOCONDENSED_BOLD_25 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 25) + ROBOTOCONDENSED_BOLD_26 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 26) + ROBOTOCONDENSED_BOLD_28 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 28) + ROBOTOCONDENSED_LIGHT_16 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Light.ttf"), 16) + ROBOTOCONDENSED_LIGHT_24 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Light.ttf"), 24) + ROBOTOCONDENSED_REGULAR_16 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 16) + ROBOTOCONDENSED_REGULAR_20 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 20) + ROBOTOCONDENSED_REGULAR_22 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 22) + ROBOTOCONDENSED_REGULAR_24 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 24) + ROBOTOCONDENSED_REGULAR_26 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 26) + ROBOTOCONDENSED_REGULAR_28 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 28) + RST = 27 DC = 25 BL = 24 - controller = None buttons = None canvas_width = 0 canvas_height = 0 @@ -44,13 +65,15 @@ class View: bus = 0 device = 0 disp = None + previous_button_width = None - def __init__(self, controller) -> None: + def __init__(self) -> None: + # Import here to avoid circular imports + from seedsigner.controller import Controller + self.controller = Controller.get_instance() - # Global Singleton - View.controller = controller - View.buttons = View.controller.buttons - View.color = View.controller.color + View.buttons = self.controller.buttons + View.color = self.controller.color View.canvas_width = View.WIDTH View.canvas_height = View.HEIGHT @@ -85,11 +108,13 @@ class View: while (cur_x != end_x or cur_y != end_y) and (rate_x != 0 or rate_y != 0): cur_x += rate_x if (rate_x > 0 and cur_x > end_x) or (rate_x < 0 and cur_x < end_x): + # We've moved too far; back up and undo that last move. cur_x -= rate_x rate_x = 0 cur_y += rate_y if (rate_y > 0 and cur_y > end_y) or (rate_y < 0 and cur_y < end_y): + # We've moved too far; back up and undo that last move. cur_y -= rate_y rate_y = 0 @@ -103,15 +128,18 @@ class View: def DispShowImageWithText(image, text, font=None, text_color="GREY", text_background=None): - image_copy = image.copy() + image_copy = image.copy().convert("RGBA") draw = ImageDraw.Draw(image_copy) + + text_overlay = Image.new("RGBA", (View.canvas_width, View.canvas_height), (255,255,255,0)) + text_overlay_draw = ImageDraw.Draw(text_overlay) if not font: font = View.COURIERNEW14 - tw, th = draw.textsize(text, font=font) + tw, th = text_overlay_draw.textsize(text, font=font) if text_background: - draw.rectangle(((240 - tw) / 2 - 3, 240 - th, (240 - tw) / 2 + tw + 3, 240), fill=text_background) - draw.text(((240 - tw) / 2, 240 - th - 1), text, fill=text_color, font=font) - View.disp.ShowImage(image_copy, 0, 0) + text_overlay_draw.rectangle(((240 - tw) / 2 - 3, 240 - th, (240 - tw) / 2 + tw + 3, 240), fill=text_background) + text_overlay_draw.text(((240 - tw) / 2, 240 - th - 1), text, fill=text_color, font=font) + View.DispShowImage(image_copy, alpha_overlay=text_overlay) def draw_modal(self, lines = [], title = "", bottom = "") -> None: @@ -208,6 +236,7 @@ class View: View.DispShowImage() return + ### ### Power Off Screen @@ -236,3 +265,28 @@ class View: def display_blank_screen(self): View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0) View.DispShowImage() + + + ### + ### Reusable components + ### + def render_previous_button(self, highlight=False): + # Set up the "back" arrow in the upper left + arrow = "<" + word_font = View.ROBOTOCONDENSED_BOLD_26 + top_padding = -3 + bottom_padding = 3 + side_padding = 3 + tw, th = word_font.getsize(arrow) + self.previous_button_width = tw + 2 * side_padding + if highlight: + font_color = "black" + background_color = View.color + else: + font_color = View.color + background_color = "black" + View.draw.rectangle((0,0, self.previous_button_width, th + top_padding + bottom_padding), fill=background_color) + View.draw.text((side_padding, top_padding), arrow, fill=font_color, font=word_font) + + + diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..aa79d2a7 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,26 @@ +# Running Tests + +You must install the test-runner dependencies: +``` +pip3 install -r tests/requirements.txt +``` + +Then make the `seedsigner` python module visible/importable to the tests by installing it: +``` +pip3 install -e . +``` + +Run the whole test suite: +``` +pytest +``` + +Run a specific test file: +``` +pytest tests/test_this_file.py +``` + +Run a specific test: +``` +pytest tests/test_this_file.py::test_this_specific_test +``` diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 00000000..7f265749 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,2 @@ +pytest==6.2.4 +mock==4.0.3 \ No newline at end of file diff --git a/tests/test_controller.py b/tests/test_controller.py new file mode 100644 index 00000000..001002d1 --- /dev/null +++ b/tests/test_controller.py @@ -0,0 +1,42 @@ +import configparser +import pytest +from mock import MagicMock +from seedsigner.controller import Controller + + + +def test_singleton_init_fails(): + """ The Controller should not allow any code to instantiate it via Controller() """ + with pytest.raises(Exception): + c = Controller() + +def test_singleton_get_instance_without_configure_fails(): + """ Calling get_instance() without first calling configure_instance() should fail """ + with pytest.raises(Exception): + c = Controller.get_instance() + +def test_singleton_get_instance_preserves_state(): + """ Changes to the Controller singleton should be preserved across calls to get_instance() """ + settings = """ + [system] + DEBUG = True + DEFAULT_LANGUAGE = en + + [display] + BACKGROUND_COLOR = #000 + TEXT_COLOR = orange + """ + config = configparser.ConfigParser() + config.read_string(settings) + + # Initialize the instance and verify that it read the config settings + Controller.configure_instance(config) + controller = Controller.get_instance() + assert controller.display_settings["text_color"] == "orange" + + # Change a value in the instance... + controller.display_settings["text_color"] = "purple" + + # ...get a new copy of the instance and confirm change + controller = Controller.get_instance() + assert controller.display_settings["text_color"] == "purple" diff --git a/tools/seed_phrase_to_qr.py b/tools/seed_phrase_to_qr.py new file mode 100644 index 00000000..0c818c75 --- /dev/null +++ b/tools/seed_phrase_to_qr.py @@ -0,0 +1,19 @@ + +if __name__ == "__main__": + import qrcode + import sys + from embit import bip39 + + print(sys.argv) + seed_phrase = sys.argv[1:] + print(seed_phrase) + + data = "" + for word in seed_phrase: + index = bip39.WORDLIST.index(word) + data += str("%04d" % index) + + qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=5, border=3) + qr.add_data(data) + qr.make(fit=True) + qr.make_image(fill_color="black", back_color="white").resize((240,240)).convert('RGB').show()
123456
A