You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1785 lines
56 KiB

4 years ago
# SPDX-FileCopyrightText: 2020 Foundation Devices, Inc. <hello@foundationdevices.com>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# SPDX-FileCopyrightText: 2018 Coinkite, Inc. <coldcardwallet.com>
# SPDX-License-Identifier: GPL-3.0-only
#
# (c) Copyright 2018 by Coinkite Inc. This file is part of Coldcard <coldcardwallet.com>
# and is covered by GPLv3 license found in COPYING.
#
# ux.py - UX/UI related helper functions
#
# NOTE: do not import from main at top.
from ur.ur_encoder import UREncoder
from ur.cbor_lite import CBOREncoder
from ur.ur import UR
from ur1.encode_ur import encode_ur
import gc
import utime
from display import Display, FontSmall
from uasyncio import sleep_ms
from uasyncio.queues import QueueEmpty
# from bip39_utils import get_words_matching_prefix
DEFAULT_IDLE_TIMEOUT = const(5*60) # (seconds) 4 hours
LEFT_MARGIN = 6
RIGHT_MARGIN = 6
TOP_MARGIN = 12
VERT_SPACING = 10
TEXTBOX_MARGIN = 6
# This signals the need to switch from current
# menu (or whatever) to show something new. The
# stack has already been updated, but the old
# top-of-stack code was waiting for a key event.
#
class AbortInteraction(Exception):
pass
class UserInteraction:
def __init__(self):
self.stack = []
def is_top_level(self):
return len(self.stack) == 1
def top_of_stack(self):
return self.stack[-1] if self.stack else None
def reset(self, new_ux):
self.stack.clear()
gc.collect()
self.push(new_ux)
async def interact(self):
# this is called inside a while(1) all the time
# - execute top of stack item
try:
await self.stack[-1].interact()
except AbortInteraction:
pass
def push(self, new_ux):
self.stack.append(new_ux)
def replace(self, new_ux):
old = self.stack.pop()
del old
self.stack.append(new_ux)
def pop(self):
if len(self.stack) < 2:
# top of stack, do nothing
return True
old = self.stack.pop()
del old
# Singleton. User interacts with this "menu" stack.
the_ux = UserInteraction()
def time_now_ms():
import utime
return utime.ticks_ms()
# TODO: Move this to it's own file
class KeyInputHandler:
def __init__(self, down="", up="", long="", repeat=None, long_duration=2000):
self.time_pressed = {}
self.down = down
self.up = up
self.long = long
self.repeat = repeat
self.long_duration = long_duration
self.kcode_state = 0
self.kcode_last_time_pressed = 0
# Returns a dictionary of all pressed keys mapped to the elapsed time that each has been pressed.
# This can be used for things like showing the progress bar for the Hold to Sign functionality.
def get_all_pressed(self):
now = time_now_ms()
pressed = {}
for key, start_time in self.time_pressed.items():
pressed[key] = now - start_time
return pressed
def __update_kcode_state(self, expected_keys, actual_key):
# print('kcode: state={} expected={} actual={}'.format(self.kcode_state, expected_key, actual_key))
if actual_key in expected_keys:
self.kcode_state += 1
self.kcode_last_time_pressed = time_now_ms()
# print(' state advanced to {}'.format(self.kcode_state))
else:
self.kcode_state = 0
# print(' state reset to {}'.format(self.kcode_state))
# If this key could start a new sequence, then call recursively so we don't skip it
if actual_key == 'u':
# print(' second chance for {}'.format(actual_key))
self.__check_kcode(actual_key)
def __check_kcode(self, key):
if self.kcode_state == 0:
self.__update_kcode_state('u', key)
elif self.kcode_state == 1:
self.__update_kcode_state('u', key)
elif self.kcode_state == 2:
self.__update_kcode_state('d', key)
elif self.kcode_state == 3:
self.__update_kcode_state('d', key)
elif self.kcode_state == 4:
self.__update_kcode_state('l', key)
elif self.kcode_state == 5:
self.__update_kcode_state('r', key)
elif self.kcode_state == 6:
self.__update_kcode_state('l', key)
elif self.kcode_state == 7:
self.__update_kcode_state('r', key)
elif self.kcode_state == 8:
self.__update_kcode_state('xy', key)
elif self.kcode_state == 9:
self.__update_kcode_state('xy', key)
# If the user seems to be entering the kcode, then the caller should
# probably not perform the normal button processing
def kcode_imminent(self):
# print('kcode_immiment() = {}'.format(True if self.kcode_state >= 8 else False))
return self.kcode_state >= 8
def kcode_complete(self):
# print('kcode_complete game = {}'.format(True if self.kcode_state == 10 else False))
return self.kcode_state == 10
def kcode_reset(self):
# print('kcode_reset()')
self.kcode_state = 0
def is_pressed(self, key):
return key in self.time_pressed
# New input function to be used in place of PressRelease and ux_press_release, ux_all_up and ux_poll_once.
async def get_event(self):
from common import keypad
# This awaited sleep is necessary to give the simulator key code a chance to insert keys into the queue
# Without it, the ux_poll_once() below will never find a key.
await sleep_ms(5)
# See if we have a character in the queue and if so process it
# Poll for an event
key, is_down = keypad.get_event()
# if key != None:
# print('key={} is_down={}'.format(key, is_down))
if key == None:
# There was nothing in the queue, so handle the time-dependent events
now = time_now_ms()
for k in self.time_pressed:
# print('k={} self.down={} self.repeat={} self.time_pressed={}'.format(k, self.down, self.repeat, self.time_pressed))
# Handle repeats
if self.repeat != None and k in self.down:
elapsed = now - self.time_pressed[k]
if elapsed >= self.repeat:
self.time_pressed[k] = now
return (k, 'repeat')
# Handle long press expiration
if k in self.long:
elapsed = now - self.time_pressed[k]
if elapsed >= self.long_duration:
del self.time_pressed[k]
return (k, 'long_press')
# Handle kcode timeout - User seemed to give up, so go back to normal key processing
if self.kcode_state > 0 and now - self.kcode_last_time_pressed >= 3000:
# print('Resetting kcode due to timeout')
self.kcode_state = 0
return None
now = time_now_ms()
# Handle the event
if is_down:
self.__check_kcode(key)
# Check to see if we are interested in this key event
if key in self.down:
self.time_pressed[key] = now
return (key, 'down')
if key in self.long:
self.time_pressed[key] = now
else: # up
# Removing this will cancel long presses of the key as well
if key in self.time_pressed:
del self.time_pressed[key]
# Check to see if we are interested in this key event
if key in self.up:
return (key, 'up')
key_to_char_map_lower = {
'2': 'abc',
'3': 'def',
'4': 'ghi',
'5': 'jkl',
'6': 'mno',
'7': 'pqrs',
'8': 'tuv',
'9': 'wxyz',
'0': ' ',
}
key_to_char_map_upper = {
'2': 'ABC',
'3': 'DEF',
'4': 'GHI',
'5': 'JKL',
'6': 'MNO',
'7': 'PQRS',
'8': 'TUV',
'9': 'WXYZ',
'0': ' ',
}
key_to_char_map_numbers = {
'1': '1',
'2': '2',
'3': '3',
'4': '4',
'5': '5',
'6': '6',
'7': '7',
'8': '8',
'9': '9',
'0': '0 ',
}
# Class that implements a state machine for editing a text string like a passphrase.
# Takes KeyInputHandler events as state change events, along with elapsed time.
IDLE_KEY_TIMEOUT = 500
PLACEHOLDER_CHAR = '^'
# TODO: Move this to its own file
class TextInputHandler:
def __init__(self, text=""):
self.text = [ch for ch in text]
self.cursor_pos = 0
self.last_key_down_time = 0
self.last_key = None
self.next_map_index = 0
self.curr_key_map = key_to_char_map_lower
def _next_key_map(self):
if self.curr_key_map == key_to_char_map_lower:
self.curr_key_map = key_to_char_map_upper
elif self.curr_key_map == key_to_char_map_upper:
self.curr_key_map = key_to_char_map_numbers
elif self.curr_key_map == key_to_char_map_numbers:
self.curr_key_map = key_to_char_map_lower
def get_mode_description(self):
if self.curr_key_map == key_to_char_map_lower:
return 'a-z'
elif self.curr_key_map == key_to_char_map_upper:
return 'A-Z'
elif self.curr_key_map == key_to_char_map_numbers:
return '0-9'
async def handle_event(self, event):
now = time_now_ms()
key, event_type = event
if event_type == 'down':
print("key={}".format(key))
if key in '*#rl':
if key == '#':
self._next_key_map()
elif key == '*':
if self.cursor_pos > 0:
# Delete the character under the cursor
self.cursor_pos = max(self.cursor_pos-1, 0)
if len(self.text) > self.cursor_pos:
del self.text[self.cursor_pos]
elif key == 'l':
self.cursor_pos = max(self.cursor_pos - 1, 0)
elif key == 'r':
# Allow cursor_pos to go at most one past the end
self.cursor_pos = min(
self.cursor_pos + 1, len(self.text))
# print('cursor_pos={} next_map_index={} key={} text={}'.format(
# self.cursor_pos, self.next_map_index, key, self.text))
self.last_key_down_time = 0
return
# Check for symbols pop-up
if key == '1' and self.curr_key_map != key_to_char_map_numbers:
# Show the symbols pop-up, otherwise fall through and handle '1' as a normal key press
symbol = await ux_show_symbols_popup('!')
if symbol == None:
return
# Insert the symbol
self.text.insert(self.cursor_pos, symbol)
self.cursor_pos += 1
return
if self.last_key == None:
print("first press of {}".format(key))
# A new keypress, so insert the first character mapped to this key
self.text.insert(
self.cursor_pos, self.curr_key_map[key][self.next_map_index])
self.cursor_pos += 1
if len(self.curr_key_map[key]) == 1:
# Just immediate commit this key since there are no other possible choices to wait for
return
elif self.last_key == key:
# User is pressing the same key within the idle timeout, so cycle to the next key
# making sure to wrap around if they keep going.
self.next_map_index = (
self.next_map_index + 1) % len(self.curr_key_map[key])
# Overwrite the last key
self.text[self.cursor_pos -
1] = self.curr_key_map[key][self.next_map_index]
else:
# User pressed a different key, but before the idle timeout, so we finalize the last
# character and start the next character as tentative.
self.cursor_pos += 1 # Finalize the last character
self.next_map_index = 0 # Reset the map index
# Append the new key
self.text.insert(
self.cursor_pos, self.curr_key_map[key][self.next_map_index])
# Insert or overwrite the character
# print('cursor_pos={} next_map_index={} key={} text={}'.format(
# self.cursor_pos, self.next_map_index, key, self.text))
# Always record the value and time of the key, regardless of which case we were in above
self.last_key = key
self.last_key_down_time = now
# This method should be called periodically (like event 10ms) if there is no key event.
# Return True if a timeout occurred, so the caller can render the updated state.
def check_timeout(self):
now = time_now_ms()
if self.last_key_down_time != 0 and now - self.last_key_down_time >= IDLE_KEY_TIMEOUT:
print("timeout!")
# Reset for next key
self.last_key_down_time = 0
self.last_key = None
self.last_index = -1
self.next_map_index = 0
return True
return False
def get_text(self):
# TODO: Remove PLACEHOLDER_CHAR from end, if present
return "".join(self.text)
async def ux_enter_text(title="Enter Text", label="Text"):
from common import dis
from display import FontSmall
font = FontSmall
input = KeyInputHandler(down='1234567890*#rl', up='xy')
text_handler = TextInputHandler()
while 1:
# redraw
dis.clear()
dis.draw_header(title, left_text=text_handler.get_mode_description())
# Draw the title
y = Display.HEADER_HEIGHT + TEXTBOX_MARGIN
dis.text(LEFT_MARGIN, y, label)
# Draw a bounding box around the text area
y += font.leading + TEXTBOX_MARGIN
dis.draw_rect(TEXTBOX_MARGIN, y, Display.WIDTH - (TEXTBOX_MARGIN * 2),
Display.HEIGHT - y - TEXTBOX_MARGIN - Display.FOOTER_HEIGHT, 1, fill_color=0, border_color=1)
# Draw the text and any other stuff
y += 4
dis.text_input(None, y, text_handler.get_text(),
cursor_pos=text_handler.cursor_pos, font=font, max_chars_per_line=12)
dis.draw_footer('BACK', 'CONTINUE', input.is_pressed(
'x'), input.is_pressed('y'))
dis.show()
# Wait for key inputs
event = None
while True:
event = await input.get_event()
if event != None:
break
# No event, so handle the idle timing
if text_handler.check_timeout():
break
if event != None:
key, event_type = event
# Check for footer button actions first
if event_type == 'down':
await text_handler.handle_event(event)
if event_type == 'up':
if key == 'x':
return None
if key == 'y':
return text_handler.get_text()
symbol_rows = [
'!@#$%^&*',
'+/-=\\?|~',
'_"`\',.:;',
'()[]{}<>',
]
async def ux_show_symbols_popup(title="Enter Passphrase"):
from common import dis
from display import FontSmall
print('ux_show_symbols_popup()')
font = FontSmall
input = KeyInputHandler(down='rlduxy', up='xy')
text_handler = TextInputHandler()
cursor_row = 0
cursor_col = 0
num_symbols_per_row = 8
num_rows = len(symbol_rows)
h_margin = 12
v_margin = 10
char_spacing = 22
width = num_symbols_per_row * char_spacing + (2 * h_margin)
height = num_rows * font.leading + (2 * v_margin)
while 1:
# redraw
x = Display.WIDTH // 2 - width // 2
y = Display.HEIGHT - Display.FOOTER_HEIGHT - height - 14
dis.draw_rect(x, y, width, height, 2, 0, 1)
x += h_margin
y += v_margin
# Draw the grid of symbols
curr_row = 0
for symbols in symbol_rows:
dis.text(x, y, symbols,
cursor_pos=(cursor_col if curr_row ==
cursor_row else None),
font=font,
fixed_spacing=char_spacing,
cursor_shape='block')
curr_row += 1
y += font.leading
dis.draw_footer('CANCEL', 'SELECT', input.is_pressed(
'x'), input.is_pressed('y'))
dis.show()
# Wait for key inputs
event = None
while True:
event = await input.get_event()
if event != None:
break
# No event, so handle the idle timing
if text_handler.check_timeout():
break
num_symbols = len(symbol_rows[cursor_row])
if event != None:
key, event_type = event
# Check for footer button actions first
if event_type == 'down':
if key == 'u':
cursor_row = (cursor_row - 1) % num_rows
elif key == 'd':
cursor_row = (cursor_row + 1) % num_rows
elif key == 'l':
cursor_col = (cursor_col - 1) % num_symbols
elif key == 'r':
cursor_col = (cursor_col + 1) % num_symbols
if event_type == 'up':
if key == 'x':
return None
if key == 'y':
return symbol_rows[cursor_row][cursor_col]
def chars_per_line(font):
return (Display.WIDTH - LEFT_MARGIN - Display.SCROLLBAR_WIDTH) // font.advance
def word_wrap(ln, font):
from common import dis
max_width = Display.WIDTH - LEFT_MARGIN - \
RIGHT_MARGIN - Display.SCROLLBAR_WIDTH
while ln:
sp = 0
last_space = 0
line_width = 0
first_non_space = 0
# Skip leading spaces
while ln[sp].isspace():
sp += 1
first_non_space = sp
while sp < len(ln):
ch = ln[sp]
if ch.isspace():
last_space = sp
ch_w = dis.char_width(ch, font)
line_width += ch_w
if line_width >= max_width:
# If we found a space, we can break there, but if we didn't
# then just break before we went over.
if last_space != 0:
sp = last_space
break
sp += 1
line = ln[first_non_space:sp]
ln = ln[sp:]
yield line
async def ux_show_story(msg, title='Passport', sensitive=False, font=FontSmall, left_btn='BACK', right_btn='CONTINUE', scroll_label=None, left_btn_enabled=True, right_btn_enabled=True, center_vertically=False, center=False):
# show a big long string, and wait for XY to continue
# - returns character used to get out (X or Y)
# - accepts a stream or string
from common import dis
ch_per_line = chars_per_line(font)
lines = []
# if title:
# # kinda weak rendering but it works.
# lines.append('\x01' + title)
if hasattr(msg, 'readline'):
msg.seek(0)
for ln in msg:
if ln[-1] == '\n':
ln = ln[:-1]
if len(ln) > ch_per_line:
lines.extend(word_wrap(ln, font))
else:
# ok if empty string, just a blank line
lines.append(ln)
# no longer needed & rude to our caller, but let's save the memory
msg.close()
del msg
gc.collect()
else:
for ln in msg.split('\n'):
if len(ln) > ch_per_line:
lines.extend(word_wrap(ln, font))
else:
# ok if empty string, just a blank line
lines.append(ln)
# trim blank lines at end
while not lines[-1]:
lines = lines[:-1]
top = 0
H = (Display.HEIGHT - Display.HEADER_HEIGHT -
Display.FOOTER_HEIGHT) // font.leading
max_visible_lines = (Display.HEIGHT - Display.HEADER_HEIGHT - Display.FOOTER_HEIGHT) // font.leading
input = KeyInputHandler(down='rldu0xy', up='xy', repeat=250)
while 1:
# redraw
dis.clear()
dis.draw_header(title)
y = Display.HEADER_HEIGHT
# Only take center_vertically into account if there are more lines than will fit on the page
if len(lines) <= max_visible_lines and center_vertically:
avail_height = (Display.HEIGHT -
Display.HEADER_HEIGHT - Display.FOOTER_HEIGHT)
text_height = len(lines) * font.leading - font.descent
y += avail_height // 2 - text_height // 2
last_to_show = min(top+H+1, len(lines))
for ln in lines[top:last_to_show]:
x = LEFT_MARGIN if not center else None
dis.text(x, y, ln, font=font)
y += font.leading
dis.scrollbar(top / len(lines), H / len(lines))
# Show the scroll_label if given and if we have not reached the bottom yet
scroll_enable_right_btn = True
right_btn_label = right_btn
if scroll_label != None:
if H + top < len(lines):
scroll_enable_right_btn = False
right_btn_label = scroll_label
dis.draw_footer(left_btn, right_btn_label,
input.is_pressed('x'), input.is_pressed('y'))
dis.show()
# Wait for key inputs
event = None
while True:
event = await input.get_event()
if event != None:
break
key, event_type = event
# print('key={} event_type={}'.format(key, event_type))
if event_type == 'down' or event_type == 'repeat':
if key == 'u':
top = max(0, top-1)
elif key == 'd':
if len(lines) > H:
top = min(len(lines) - H, top+1)
if event_type == 'down':
if key == '0':
top = 0
if event_type == 'up':
# No left_btn means don't exit on the 'x' key
if left_btn_enabled and (key == 'x'):
return key
if key == 'y':
if scroll_enable_right_btn:
if right_btn_enabled:
return key
else:
if len(lines) > H:
top = min(len(lines) - H, top+1)
async def ux_confirm(msg, negative_btn='NO', positive_btn='YES', center=True, center_vertically=True):
resp = await ux_show_story(msg, center=center, center_vertically=center_vertically, left_btn=negative_btn, right_btn=positive_btn)
return resp == 'y'
async def ux_dramatic_pause(msg, seconds):
from common import dis
# show a full-screen msg, with a dramatic pause + progress bar
n = seconds * 8
dis.fullscreen(msg)
for i in range(n):
dis.progress_bar_show(i/n)
await sleep_ms(125)
def blocking_sleep(ms):
start = utime.ticks_ms()
while (1):
now = utime.ticks_ms()
if now - start >= ms:
return
def save_error_log(msg, filename):
from files import CardSlot, CardMissingError
wrote_to_sd = False
try:
with CardSlot() as card:
# Full path and short filename
fname, nice = card.get_file_path(filename)
with open(fname, 'wb') as fd:
line = 'Saved %s to microSD' % nice
fd.write(msg)
wrote_to_sd = True
except CardMissingError:
line = 'Insert microSD to save log'
except Exception:
line = 'Failed to save %s' % filename
return wrote_to_sd, line
def ux_show_fatal(msg):
from common import dis
from display import FontTiny
font = FontTiny
ch_per_line = chars_per_line(font)
lines = []
for ln in msg.split('\n'):
if len(ln) > ch_per_line:
lines.extend(word_wrap(ln, font))
else:
# ok if empty string, just a blank line
lines.append(ln)
# Draw
top = 0
max_visible_lines = (
Display.HEIGHT - Display.HEADER_HEIGHT) // font.leading + 1
num_lines = len(lines)
max_top = max(0, num_lines - max_visible_lines)
PER_LINE_DELAY = 500
LONG_DELAY = 2000
INITIAL_DELAY = 5000
delay = INITIAL_DELAY
direction = 1
# Write
filename = 'error.log'
wrote_to_sd, lines[0] = save_error_log(msg, filename)
while (1):
# Draw
dis.clear()
dis.draw_header('Error')
# Draw the subset of lines that is visible
y = Display.HEADER_HEIGHT
for ln in lines[top:top+max_visible_lines]:
dis.text(LEFT_MARGIN, y, ln, font=font)
y += font.leading
dis.show()
blocking_sleep(delay)
# More lines than can be displayed - early exit if no scrolling needed
if num_lines <= max_visible_lines:
return
# Change direction if we reach the top or bottom
if direction == 1:
top = min(max_top, top + 1)
if top == max_top:
direction = -1
else:
top = max(0, top - 1)
if top == 0:
direction = 1
delay = PER_LINE_DELAY
if top == 0 or top == max_top:
if wrote_to_sd is False:
wrote_to_sd, lines[0] = save_error_log(msg, filename)
delay = LONG_DELAY
def show_fatal_error(msg):
all_lines = msg.split('\n')[0:]
# Remove lines we don't want to shorten
lines = all_lines[1:-2]
# Shorten file path to only file name
# TODO: FIX THIS: lines = [line[line.index('passport-mp')+19:] for line in lines]
# Insert lines we want to add for readability or keep from the original msg
lines.insert(0, "")
lines.insert(1, "")
lines.append("")
lines.append(all_lines[-2])
ux_show_fatal("\n".join(lines))
def restore_menu():
# redraw screen contents after distrupting it w/ non-ux things (usb upload)
m = the_ux.top_of_stack()
if hasattr(m, 'update_contents'):
m.update_contents()
if hasattr(m, 'show'):
m.show()
def abort_and_goto(m):
# TODO: Clear out keypad buffer
the_ux.reset(m)
def abort_and_push(m):
# TODO: Clear out keypad buffer
the_ux.push(m)
async def show_qr_codes(addrs, is_alnum, start_n):
o = QRDisplay(addrs, is_alnum, start_n, sidebar=None)
await o.interact_bare()
class QRDisplay(UserInteraction):
# Show a QR code for (typically) a list of addresses. Can only work on Mk3
def __init__(self, addrs, is_alnum, start_n=0, sidebar=None):
self.is_alnum = is_alnum
self.idx = 0 # start with first address
self.invert = False # looks better, but neither mode is ideal
self.addrs = addrs
self.sidebar = sidebar
self.start_n = start_n
self.qr_data = None
self.left_down = False
self.right_down = False
self.input = KeyInputHandler(down='xyudlr', up='xy')
def render_qr(self, msg):
# Version 2 would be nice, but can't hold what we need, even at min error correction,
# so we are forced into version 3 = 29x29 pixels
# - see <https://www.qrcode.com/en/about/version.html>
# - to display 29x29 pixels, we have to double them up: 58x58
# - not really providing enough space around it
# - inverted QR (black/white swap) still readable by scanners, altho wrong
from utils import imported
with imported('uQR') as uqr:
if self.is_alnum:
# targeting 'alpha numeric' mode, typical len is 42
ec = uqr.ERROR_CORRECT_Q
assert len(msg) <= 47
else:
# has to be 'binary' mode, altho shorter msg, typical 34-36
ec = uqr.ERROR_CORRECT_M
assert len(msg) <= 42
q = uqr.QRCode(version=3, box_size=1, border=0,
mask_pattern=3, error_correction=ec)
if self.is_alnum:
here = uqr.QRData(msg.upper().encode('ascii'),
mode=uqr.MODE_ALPHA_NUM, check_data=False)
else:
here = uqr.QRData(msg.encode('ascii'),
mode=uqr.MODE_8BIT_BYTE, check_data=False)
q.add_data(here)
q.make(fit=False)
self.qr_data = q.get_matrix()
def redraw(self):
# Redraw screen.
from common import dis
from display import FontTiny
font = FontTiny
inv = self.invert
# what we are showing inside the QR
msg = self.addrs[self.idx]
# make the QR, if needed.
if not self.qr_data:
# dis.busy_bar(True)
self.render_qr(msg)
# Draw display
if inv:
dis.dis.fill_rect(0, 0, Display.WIDTH,
Display.HEIGHT - Display.FOOTER_HEIGHT + 1, 1)
else:
dis.clear()
y = TOP_MARGIN
# Draw the derivation path
if len(self.addrs) > 1:
path = "Path: {}".format(self.start_n + self.idx)
dis.text(None, y, path, font, invert=inv)
y += font.leading + VERT_SPACING
w = 29 # because version=3
module_size = 6 # Each "dot" in a QR code is called a module
pixel_width = w * module_size
frame_width = pixel_width + (module_size * 2)
# QR code offsets
XO = (Display.WIDTH - pixel_width) // 2
YO = y
dis.dis.fill_rect(XO - module_size, YO -
module_size, frame_width, frame_width, 0 if inv else 1)
# Draw the actual QR code
data = self.qr_data
for qx in range(w):
for qy in range(w):
px = data[qx][qy]
X = (qx*module_size) + XO
Y = (qy*module_size) + YO
dis.dis.fill_rect(X, Y, module_size,
module_size, px if inv else (not px))
# Show the data encoded by the QR code
y += w*module_size + VERT_SPACING + 3
sidebar, ll = self.sidebar or (msg, 20)
for i in range(0, len(sidebar), ll):
dis.text(None, y, sidebar[i:i+ll], font, inv)
y += font.leading
dis.draw_footer('BACK', 'INVERT', self.input.is_pressed(
'x'), self.input.is_pressed('y'))
dis.show()
async def interact_bare(self):
self.redraw()
while 1:
event = await self.input.get_event()
if event != None:
key, event_type = event
if event_type == 'down':
if key == 'u' or key == 'l':
if self.idx > 0:
self.idx -= 1
self.qr_data = None
elif key == 'd' or key == 'r':
if self.idx != len(self.addrs)-1:
self.idx += 1
self.qr_data = None
else:
continue
elif event_type == 'up':
if key == 'x':
self.redraw()
break
elif key == 'y':
self.invert = not self.invert
else:
continue
self.redraw()
async def interact(self):
await self.interact_bare()
the_ux.pop()
async def ux_show_text_as_ur(title='QR Code', msg='', qr_text=''):
o = DisplayURCode(title, msg, qr_text)
await o.interact_bare()
gc.collect()
def qr_get_module_size_for_version(version):
# 1 -> 21
# 2 -> 25
# etc.
return version * 4 + 17
def qr_buffer_size_for_version(version):
size = qr_get_module_size_for_version(version)
return ((size * size) + 7) // 8
class DisplayURCode(UserInteraction):
# Show a QR code or a series of codes in Blockchain Commons' UR format
# Purpose is to allow a QR code to be scanned, so we make it as big as possible
# given our screen size, but if it's too big, we display a series of images
# instead.
def __init__(self, title, msg, qr_text):
self.title = title
self.msg = msg
self.qr_text = qr_text
# self.qr = None
self.input = KeyInputHandler(down='xy', up='xy')
self.ur_version = 1
self.qr_version_idx = 0 # "version" for QR codes essentially maps to the size
self.qr_versions = [22, 12, 8]
self.render_id = 0
self.last_render_id = -1;
self.generate_qr_data()
self.qr_data = None
self.curr_part = 0
def generate_qr_data(self):
# We collect before and after to ensure the most available memory
self.parts = None
gc.collect()
# Generate the parts
if self.ur_version == 1:
# UR 1.0
self.parts = encode_ur(self.qr_text, fragment_capacity=self.get_ur_max_len())
elif self.ur_version == 2:
# UR 2.0
encoder = CBOREncoder()
encoder.encodeBytes(self.qr_text)
ur_obj = UR("bytes", encoder.get_bytes())
self.ur_encoder = UREncoder(ur_obj, 30)
self.parts = []
while not self.ur_encoder.is_complete():
part = self.ur_encoder.next_part()
print('part={}'.format(part))
self.parts.append(part)
else:
raise ValueError('Invalid UR version')
gc.collect()
def set_next_density(self):
self.qr_version_idx = (self.qr_version_idx + 1) % len(self.qr_versions)
# TODO: Determine best values for version, and max len
def get_ur_max_len(self):
if self.qr_version_idx == 0:
return 500
elif self.qr_version_idx == 1:
return 200
else:
return 60
def render_qr(self, data):
from utils import imported
if self.last_render_id != self.render_id:
self.last_render_id = self.render_id
# Release old buffer and collect so we can reuse that memory
self.qr_data = None
gc.collect()
# Render QR data to buffer
print('qr={}'.format(data.upper()))
encoded_data = data.upper().encode('ascii')
ll = len(encoded_data)
from foundation import QRCode
qrcode = QRCode()
version = qrcode.fit_to_version(ll)
buf_size = qr_buffer_size_for_version(version)
self.modules_count = qr_get_module_size_for_version(version)
# print('fit_to_version({}) = {} buffer size = {}'.format(ll, version,buf_size))
# TODO: Use correct buffer size here or just allocate once outside the loop (largest possible size)
out_buf = bytearray(2000)
result = qrcode.render(encoded_data, version, 0, out_buf)
self.qr_data = out_buf
def redraw(self):
# Redraw screen.
from common import dis
from display import FontTiny
TOP_MARGIN = 9
VERT_SPACING = 10
font = FontTiny
# Make the QR, if needed
#if not self.qr_data[self.curr_part]:
# print('rendering QR code for entry {}: "{}" len={}'.format(self.curr_part, self.parts[self.curr_part], len(self.parts[self.curr_part])))
self.render_qr(self.parts[self.curr_part])
# Draw QR display
dis.clear()
dis.draw_header(self.title, left_text='{}/{}'.format(self.curr_part + 1, len(self.parts)))
y = Display.HEADER_HEIGHT + TOP_MARGIN
w = self.modules_count
# print('modules_count={}'.format(w))
module_pixel_width = (Display.WIDTH - 20) // w
# print('module_pixel_width={}'.format(module_pixel_width))
total_pixel_width = w * module_pixel_width
frame_width = total_pixel_width + (module_pixel_width * 2)
# QR code offsets
XO = (Display.WIDTH - total_pixel_width) // 2
# Center vertically now that we have no label underneath
YO = ((Display.HEIGHT - Display.HEADER_HEIGHT - Display.FOOTER_HEIGHT) - total_pixel_width ) // 2 + Display.HEADER_HEIGHT
dis.dis.fill_rect(XO - module_pixel_width, YO -
module_pixel_width, frame_width, frame_width, 1)
# Draw the actual QR code
# print('qr_data = {}'.format(self.qr_data))
if self.qr_data != None:
for qy in range(w):
for qx in range(w):
offset = qy * self.modules_count + qx
px = (self.qr_data[offset >> 3]) & (1 << (7 - (offset & 0x07)))
X = (qx*module_pixel_width) + XO
Y = (qy*module_pixel_width) + YO
dis.dis.fill_rect(X, Y, module_pixel_width, module_pixel_width, not px)
dis.draw_footer(
'BACK',
'RESIZE',
self.input.is_pressed('x'),
self.input.is_pressed('y')
)
dis.show()
self.last_frame_render_time = time_now_ms()
async def interact_bare(self):
self.redraw()
while 1:
event = await self.input.get_event()
if event != None:
key, event_type = event
if event_type == 'up':
if key == 'x':
self.redraw()
break
elif key == 'y':
self.set_next_density()
self.generate_qr_data()
self.curr_part = 0
self.render_id += 1
else:
# Only need to check timer and advance part number if we have more than one part
if len(self.parts) > 1:
now = time_now_ms()
elapsed_time = now - self.last_frame_render_time
# print('elapsed_time={}'.format(elapsed_time))
if elapsed_time > 1:
# Show the next part
self.curr_part = (self.curr_part + 1) % len(self.parts)
self.redraw()
self.render_id += 1
continue
self.redraw()
async def interact(self):
await self.interact_bare()
the_ux.pop()
async def ux_enter_number(prompt, max_value):
# return the decimal number which the user has entered
# - default/blank value assumed to be zero
# - clamps large values to the max
from common import dis
from display import FontTiny, FontSmall
from math import log
# allow key repeat on X only
press = PressRelease('1234567890y')
footer = "X to DELETE, or OK when DONE."
y = 26
value = ''
max_w = int(log(max_value, 10) + 1)
while 1:
dis.clear()
dis.text(0, 0, prompt)
# text centered
if value:
bx = dis.text(None, y, value)
dis.icon(bx+1, y+11, 'space')
else:
dis.icon(64-7, y+11, 'space')
dis.text(None, -1, footer, FontTiny)
dis.show()
# ========================================
# ========================================
# ========================================
# ========================================
# TODO: Replace with KeyInputHandler
# ========================================
# ========================================
# ========================================
# ========================================
ch = await press.wait()
if ch == 'y':
if not value:
return 0
return min(max_value, int(value))
elif ch == 'x':
if value:
value = value[0:-1]
else:
# quit if they press X on empty screen
return 0
else:
if len(value) == max_w:
value = value[0:-1] + ch
else:
value += ch
# cleanup leading zeros and such
value = str(int(value))
THRESHOLD = 128
def convert_to_bw(img, w, h):
dest_bytes_per_line = ((w + 7) // 8)
dest_len = dest_bytes_per_line * h
dest = bytearray(dest_len)
for y in range(h):
for x in range(w):
src_offset = (y*w) + x
color = img[src_offset]
dest_offset = (y*dest_bytes_per_line) + (x // 8)
# print('dest_offset=' + str(dest_offset))
mask = 0x80 >> x % 8
if color < THRESHOLD:
dest[dest_offset] = dest[dest_offset] | mask
return dest
async def ux_scan_qr_code(title):
from common import dis, qr_buf, viewfinder_buf
from display import FontLarge, FontSmall
from ur.ur_decoder import URDecoder
from ur1.decode_ur import decode_ur, extract_single_workload, Workloads
import utime
from constants import VIEWFINDER_WIDTH, VIEWFINDER_HEIGHT, CAMERA_WIDTH, CAMERA_HEIGHT
from foundation import Camera, QR
font = FontSmall
# Create the Camera connection
cam = Camera()
cam.enable()
# Create QR decoder
qr = QR(CAMERA_WIDTH, CAMERA_HEIGHT, qr_buf)
qr_code = None
data = None
# Premptively create a URDecoder too - we don't know if we need it yet
ur_decoder = URDecoder()
percent_complete = 0
input = KeyInputHandler(up='xy', down='xy')
fps_start = utime.ticks_us()
frame_count = 0
ur_version = 1
workloads = Workloads()
parts_received = 0
total_parts = 0
while True:
frame_start = utime.ticks_us()
snapshot_start = frame_start
result = cam.snapshot(qr_buf, CAMERA_WIDTH, CAMERA_HEIGHT,
viewfinder_buf, VIEWFINDER_WIDTH, VIEWFINDER_HEIGHT)
snapshot_end = utime.ticks_us()
if not result:
print("ERROR: cam.copy_capture() returned False!")
# TODO: Show some error to the user!!!
return None
draw_start = utime.ticks_us();
dis.clear()
dis.draw_header(title)
dis.image(0, Display.HEADER_HEIGHT, VIEWFINDER_WIDTH,
VIEWFINDER_HEIGHT, viewfinder_buf)
OFFSET = 6
SIZE = 30
THICKNESS = 6
LEFT_X = OFFSET
RIGHT_X = Display.WIDTH - OFFSET * 2
Y = Display.HEADER_HEIGHT + OFFSET
# # Upper left
# dis.draw_rect(LEFT_X, Y, SIZE, THICKNESS, 0, fill_color=1)
# dis.draw_rect(LEFT_X, Y + THICKNESS, SIZE, THICKNESS, 0, fill_color=0)
# dis.draw_rect(LEFT_X, Y, THICKNESS, SIZE, 0, fill_color=1)
# dis.draw_rect(LEFT_X + THICKNESS, Y + THICKNESS, THICKNESS, SIZE - THICKNESS, 0, fill_color=0)
# # Upper right
# dis.draw_rect(RIGHT_X - SIZE, Y, SIZE, THICKNESS, 0, fill_color=1)
# dis.draw_rect(RIGHT_X - SIZE, Y + THICKNESS, SIZE, THICKNESS, 0, fill_color=0)
# dis.draw_rect(RIGHT_X, Y, THICKNESS, SIZE, 0, fill_color=1)
# dis.draw_rect(RIGHT_X - THICKNESS, Y + THICKNESS, THICKNESS, SIZE - THICKNESS, 0, fill_color=0)
right_label = '{} OF {}'.format(parts_received, total_parts) if total_parts > 0 else 'SCANNING...'
dis.draw_footer('BACK', right_label,
left_down=input.is_pressed('x'), right_down=input.is_pressed('y'))
draw_end = utime.ticks_us();
show_start = utime.ticks_us();
dis.show()
show_end = utime.ticks_us();
# Try to decode the data
decode_start = utime.ticks_us()
qr_code = qr.find_qr_codes()
# print('find_qr_codes() out')
decode_end = utime.ticks_us()
if qr_code != None:
data = qr_code
print('qr_code={}'.format(qr_code))
# See if this looks like a ur code
ur_start = utime.ticks_us()
ur_end = 0
try:
if ur_version == 1:
workloads.add(data)
parts_received, total_parts = workloads.get_progress()
if workloads.is_complete():
data = decode_ur(workloads.workloads)
break
elif ur_version == 2:
import math
if ur_decoder.receive_part(qr_code) == True:
print('Part was accepted')
else:
print('Part was NOT accepted')
ur_end = utime.ticks_us()
if ur_decoder.is_success():
result = ur_decoder.result_message()
print('Success! len={} result={}'.format(
len(result.cbor), result))
data = result.cbor
break
percent_complete = math.floor(
ur_decoder.estimated_percent_complete() * 100)
except Exception as e:
ur_end = utime.ticks_us()
print('Failed to parse UR!')
import sys
print('Exception: {}'.format(e))
sys.print_exception(e)
# Doesn't look like it's a UR code, so interpret as a normal QR code and return the data
data = qr_code
break
print('ur decode: {}ms'.format(ur_end - ur_start))
else:
pass
# print("******* NO QR CODE FOUND!")
key_start = utime.ticks_us()
# Check for key input to see if we should back out
event = await input.get_event()
if event != None:
key, event_type = event
if event_type == 'up':
if key == 'x':
data = None
break
key_end = utime.ticks_us()
# An extra sleep to avoid redrawing so much
# TODO: See if this is necessary on actual hardware - may be able to reduce the duration of the sleep
# TODO: Balance between screen refresh rate and battery drain.
frame_count += 1
now = utime.ticks_us()
snapshot_ms = (snapshot_end - snapshot_start) / 1000
draw_ms = (draw_end - draw_start) / 1000
show_ms = (show_end - show_start) / 1000
decode_ms = (decode_end - decode_start) / 1000
key_ms = (key_end - key_start) / 1000
total_ms = snapshot_ms + draw_ms + show_ms + decode_ms + key_ms
measured_frame_ms = (now - frame_start) / 1000
fps = frame_count / ((now - fps_start) / 1000000)
if frame_count % 10 == 0:
print_start = utime.ticks_us()
print('Frame Stats:')
print(' {:>3.2f}ms Snapshot'.format(snapshot_ms))
print(' {:>3.2f}ms Draw'.format(draw_ms))
print(' {:>3.2f}ms Update to Screen'.format(show_ms))
print(' {:>3.2f}ms Decode'.format(decode_ms))
print(' {:>3.2f}ms Check keys'.format(key_ms))
print(' --------')
print(' {:>3.2f}ms Total of the above\n'.format(total_ms))
print(' {:>3.2f}ms Total measured frame time'.format(measured_frame_ms))
print(' {:>3.2f}ms Missing time\n'.format(measured_frame_ms - total_ms))
print(' {:>3.2f}fps Frame rate'.format(fps))
print_end = utime.ticks_us()
print_ms = (print_end - print_start) / 1000
print(' {:03.1f}ms Print time'.format(print_ms))
# await sleep_ms(10)
# Turn off camera after capturing is done!
print('cam.disable() starting')
cam.disable()
print('cam.disable() done')
# Test sha256 from trezor
return data
# Keeping this for a bit as an example of HOLD TO SIGN
# async def ux_scan_qr_code(title):
# # show a big long string, and wait for XY to continue
# # - returns character used to get out (X or Y)
# # - accepts a stream or string
# from common import dis
# from display import FontLarge, FontSmall
# from camera import Camera, CAMERA_WIDTH, CAMERA_HEIGHT
# from foundation import QR
# font = FontSmall
# # Create the Camera connection
# cam = Camera()
# cam.enable()
# # Create QR decoder
# qr = QR(CAMERA_WIDTH, CAMERA_HEIGHT, cam.get_image_buffer())
# qr_code = None
# is_signed = False
# is_signing = False
# signing_progress = 0
# SIGNING_DURATION = 2000
# input = KeyInputHandler(up='xy', down='y', long='y',
# long_duration=SIGNING_DURATION)
# while True:
# dis.clear()
# dis.draw_header(title)
# if not is_signing:
# img = cam.capture()
# if img == None:
# print("No image received!")
# # TODO: Show some error!!!
# return None
# preview = convert_to_bw(img, CAMERA_WIDTH, CAMERA_HEIGHT)
# dis.image(Display.WIDTH // 2 - 120, 31, 240, 320, preview)
# qr_code = qr.find_qr_codes(img)
# if qr_code != None:
# break
# # print("qr_code=" + qr_code)
# # lines = []
# # lines.extend(word_wrap(qr_code, font))
# # y = Display.HEIGHT - (len(lines) * font.leading)
# # # print("Display.HEIGHT=" + str(Display.HEIGHT) + " len(lines)=" + str(len(lines)) + " font.height=" + str(font.height))
# # for ln in lines:
# # dis.clear_rect(0, y, Display.WIDTH, font.leading)
# # dis.text(None, y, ln)
# # y += font.leading
# else:
# # Draw Signing UI
# if is_signed:
# dis.text(None, 100, "Signing Successful!")
# else:
# dis.text(None, 100, "Signing Transaction...")
# dis.draw_rect(10, 140, Display.WIDTH - 20, 40, 2, 0, 1)
# dis.draw_rect(14, 144, int((Display.WIDTH - 28)
# * signing_progress), 32, 0, 1, 0)
# dis.show()
# event = await input.get_event()
# if event != None:
# key, event_type = event
# if event_type == 'up':
# if key == 'x':
# break
# if key == 'y':
# if not is_signed:
# is_signing = False
# signing_progress = 0
# if event_type == 'down':
# if key == 'y':
# is_signing = True
# if event_type == 'long_press':
# if key == 'y':
# is_signed = True
# signing_progress = 1
# if is_signing and not is_signed:
# all_pressed = input.get_all_pressed()
# if 'y' in all_pressed:
# elapsed = all_pressed['y']
# # Handle the elapsed time calc
# signing_progress = elapsed / SIGNING_DURATION
# # print("elapsed={} signing_progress={}".format(
# # elapsed, signing_progress))
# # An extra sleep to avoid redrawing so much
# await sleep_ms(100)
# # Turn off camera after capturing is done!
# cam.disable()
# return qr_code
async def ux_show_story_sequence(stories):
story_idx = 0
while 1:
s = stories[story_idx]
key = await ux_show_story(
s.get('msg'),
title=s.get('title', 'Passport'),
sensitive=s.get('sensitive', False),
left_btn=s.get('left_btn', 'BACK'),
right_btn=s.get('right_btn', 'CONTINUE'),
center=s.get('center', False),
center_vertically=s.get('center_vertically', False),
scroll_label=s.get('`scroll_label`', None))
if key == 'x':
if story_idx == 0:
return 'x'
else:
story_idx -= 1
elif key == 'y':
if story_idx == len(stories) - 1:
return 'y'
else:
story_idx += 1
async def ux_show_word_list(title, words, heading1='', heading2=None, left_aligned_center=False, left_btn='NO', right_btn='YES'):
from common import dis
font = FontSmall
input = KeyInputHandler(up='xy', down='xy')
# Figure out horizonal start - we want to center based on the longest word
x = None
if left_aligned_center:
longest_word_width = 0
for word in words:
px_width = dis.width(word, font)
if px_width > longest_word_width:
longest_word_width = px_width
x = Display.HALF_WIDTH - (longest_word_width // 2)
while True:
dis.clear()
dis.draw_header(title)
y = Display.HEADER_HEIGHT + TOP_MARGIN
dis.text(None, y, heading1, font=font)
y += font.leading
if heading2 != None:
dis.text(None, y, heading2, font=font)
y += font.leading * 2
else:
y += font.leading
# Show the word list
for word in words:
dis.text(x, y, word, font=font)
y += font.leading
dis.draw_footer(left_btn=left_btn,
right_btn=right_btn,
left_down=input.is_pressed('x'),
right_down=input.is_pressed('y'))
dis.show()
while 1:
event = await input.get_event()
if event != None:
break
key, event_type = event
if event_type == 'up':
if key in 'xy':
return key
async def ux_enter_pin(title, heading='Enter PIN', message=None):
from common import dis
MAX_PIN_PART_LEN = 6
MIN_PIN_PART_LEN = 2
PIN_BOX_W, PIN_BOX_H = dis.icon_size('box')
PIN_BOX_SPACING = (dis.WIDTH - PIN_BOX_W *
MAX_PIN_PART_LEN) // (MAX_PIN_PART_LEN + 1)
PIN_BOX_ADVANCE = PIN_BOX_W + PIN_BOX_SPACING
font = FontSmall
input = KeyInputHandler(up='xy0123456789', down='xy0123456789*')
pin = ''
pressed = False
while True:
dis.clear()
dis.draw_header(title)
filled = len(pin)
if pressed:
filled -= 1
y = dis.HEADER_HEIGHT + 20
dis.text(None, y, heading, font=FontSmall)
y += FontSmall.leading + 20
num_boxes = filled
total_width = (filled * PIN_BOX_W) + ((num_boxes - 1) * PIN_BOX_SPACING)
x = Display.HALF_WIDTH - (total_width // 2) - (PIN_BOX_W // 2) - 4
for _idx in range(filled):
dis.icon(x, y, 'xbox')
x += PIN_BOX_ADVANCE
if pressed:
dis.icon(x, y, 'tbox')
else:
if len(pin) != MAX_PIN_PART_LEN:
dis.icon(x, y, 'box')
if message:
dis.text(None, Display.HEIGHT - Display.FOOTER_HEIGHT -
FontTiny.leading, message, FontTiny)
dis.draw_footer("BACK", "ENTER", input.is_pressed('x'),
input.is_pressed('y'))
dis.show()
# Interaction
while True:
event = await input.get_event()
if event != None:
break
key, event_type = event
if event_type == 'down':
if key == '*':
# Delete one digit from the PIN
if pin:
pin = pin[:-1]
elif key in '0123456789':
pressed = True
# Add the number to the PIN or replace the last digit
if len(pin) == MAX_PIN_PART_LEN:
pin = pin[:-1] + key
else:
pin += key
elif event_type == 'up':
if key == 'x':
return None
elif key == 'y':
if len(pin) < MIN_PIN_PART_LEN:
# they haven't given enough yet
continue
else:
print('RETURNING PIN = {}'.format(pin))
return pin
elif key in '0123456789':
pressed = False
async def ux_shutdown():
from common import system
confirm = await ux_confirm("Are you sure you want to shutdown?", center=True, center_vertically=True)
if confirm:
print('SHUTTING DOWN!')
# TODO: CLEAR THE SCREEN BEFORE POWERING DOWN!
system.shutdown()
return