Merge pull request #58 from SeedSigner/0_4_3

Merge 0.4.3 changes into main branch
This commit is contained in:
SeedSigner
2021-07-29 11:04:06 -04:00
committed by GitHub
33 changed files with 2033 additions and 178 deletions
+1
View File
@@ -1,2 +1,3 @@
.DS_Store
__pycache__/
src/seedsigner.egg-info/
+4
View File
@@ -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)
Binary file not shown.
Binary file not shown.
+60 -7
View File
@@ -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 = """
<html>
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -9,6 +16,7 @@ def generate_qr_template(qr_size, block_size=5, show_timing_marks=False):
<style>
body {
margin-top: 5em;
text-align: center;
font-family: "Open Sans", sans-serif;
-webkit-print-color-adjust: exact;
@@ -29,6 +37,11 @@ def generate_qr_template(qr_size, block_size=5, show_timing_marks=False):
width: 11px;
height: 12px;
}
.qr_table {
margin-top: 5em;
margin-right: 4em;
float: right;
}
.filled {
background-color: black;
border: 1px solid black;
@@ -55,19 +68,59 @@ def generate_qr_template(qr_size, block_size=5, show_timing_marks=False):
.no_border {
border: none;
}
.word_list_table {
"""
if num_words == 12:
html += "margin-left: 10em;\n"
else:
html += "margin-left: 0;\n"
html += """
float: left;
}
.word_list {
text-align: right;
padding-top: 2em;
padding-left: 1em;
padding-right: 1em;
}
.word_row {
height: 2.5em;
}
.word_num {
width: 1em;
text-align: right;
color: #999;
}
.word_blank {
color: #ccc;
}
</style>
<body>
"""
if qr_size == 21:
html += f"""<div class="title">{qr_size}x{qr_size} QR Code</div>"""
elif qr_size == 25:
html += f"""<div class="title">12-word Seed</div>"""
else:
html += f"""<div class="title">24-word Seed</div>"""
html += """
<table align="center" class="word_list_table">
<tr>
<td class="word_list">
"""
for i in range(1, num_words + 1):
if i == 13:
html += """
</td>
<td class="word_list">
"""
html += f"""<div class="word_row">
<span class="word_num">{i}:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
"""
html += "</td></tr></table>"
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 += """<table align="center">"""
html += """<table align="center" class="qr_table">"""
html += f"""<tr rowspan="{block_size}"><td class="col_block_divider row_block_divider"></td>"""
for j in range(0, math.ceil(qr_size/block_size)):
html += f"""<td colspan="{block_size}" class="col_name col_block_divider">{j + 1}</td>"""
+70 -1
View File
@@ -6,6 +6,7 @@
<style>
body {
margin-top: 5em;
text-align: center;
font-family: "Open Sans", sans-serif;
-webkit-print-color-adjust: exact;
@@ -26,6 +27,11 @@
width: 11px;
height: 12px;
}
.qr_table {
margin-top: 5em;
margin-right: 4em;
float: right;
}
.filled {
background-color: black;
border: 1px solid black;
@@ -52,9 +58,72 @@
.no_border {
border: none;
}
.word_list_table {
margin-left: 10em;
float: left;
}
.word_list {
text-align: right;
padding-top: 2em;
padding-left: 1em;
padding-right: 1em;
}
.word_row {
height: 2.5em;
}
.word_num {
width: 1em;
text-align: right;
color: #999;
}
.word_blank {
color: #ccc;
}
</style>
<body>
<div class="title">12-word Seed</div><table align="center"><tr rowspan="5"><td class="col_block_divider row_block_divider"></td><td colspan="5" class="col_name col_block_divider">1</td><td colspan="5" class="col_name col_block_divider">2</td><td colspan="5" class="col_name col_block_divider">3</td><td colspan="5" class="col_name col_block_divider">4</td><td colspan="5" class="col_name col_block_divider">5</td></tr><tr>
<table align="center" class="word_list_table">
<tr>
<td class="word_list">
<div class="word_row">
<span class="word_num">1:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">2:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">3:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">4:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">5:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">6:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">7:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">8:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">9:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">10:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">11:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">12:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
</td></tr></table><table align="center" class="qr_table"><tr rowspan="5"><td class="col_block_divider row_block_divider"></td><td colspan="5" class="col_name col_block_divider">1</td><td colspan="5" class="col_name col_block_divider">2</td><td colspan="5" class="col_name col_block_divider">3</td><td colspan="5" class="col_name col_block_divider">4</td><td colspan="5" class="col_name col_block_divider">5</td></tr><tr>
<td rowspan="5" class="row_name row_block_divider">A</td><td id="0_0"></td><td id="0_1"></td><td id="0_2"></td><td id="0_3"></td><td id="0_4"></td><td id="0_5"></td><td id="0_6"></td><td id="0_7"></td><td id="0_8"></td><td id="0_9"></td><td id="0_10"></td><td id="0_11"></td><td id="0_12"></td><td id="0_13"></td><td id="0_14"></td><td id="0_15"></td><td id="0_16"></td><td id="0_17"></td><td id="0_18"></td><td id="0_19"></td><td id="0_20"></td><td id="0_21"></td><td id="0_22"></td><td id="0_23"></td><td id="0_24"></td></tr>
<tr>
<td id="1_0"></td><td id="1_1"></td><td id="1_2"></td><td id="1_3"></td><td id="1_4"></td><td id="1_5"></td><td id="1_6"></td><td id="1_7"></td><td id="1_8"></td><td id="1_9"></td><td id="1_10"></td><td id="1_11"></td><td id="1_12"></td><td id="1_13"></td><td id="1_14"></td><td id="1_15"></td><td id="1_16"></td><td id="1_17"></td><td id="1_18"></td><td id="1_19"></td><td id="1_20"></td><td id="1_21"></td><td id="1_22"></td><td id="1_23"></td><td id="1_24"></td></tr>
+109 -1
View File
@@ -6,6 +6,7 @@
<style>
body {
margin-top: 5em;
text-align: center;
font-family: "Open Sans", sans-serif;
-webkit-print-color-adjust: exact;
@@ -26,6 +27,11 @@
width: 11px;
height: 12px;
}
.qr_table {
margin-top: 5em;
margin-right: 4em;
float: right;
}
.filled {
background-color: black;
border: 1px solid black;
@@ -52,9 +58,111 @@
.no_border {
border: none;
}
.word_list_table {
margin-left: 0;
float: left;
}
.word_list {
text-align: right;
padding-top: 2em;
padding-left: 1em;
padding-right: 1em;
}
.word_row {
height: 2.5em;
}
.word_num {
width: 1em;
text-align: right;
color: #999;
}
.word_blank {
color: #ccc;
}
</style>
<body>
<div class="title">24-word Seed</div><table align="center"><tr rowspan="5"><td class="col_block_divider row_block_divider"></td><td colspan="5" class="col_name col_block_divider">1</td><td colspan="5" class="col_name col_block_divider">2</td><td colspan="5" class="col_name col_block_divider">3</td><td colspan="5" class="col_name col_block_divider">4</td><td colspan="5" class="col_name col_block_divider">5</td><td colspan="5" class="col_name col_block_divider">6</td></tr><tr>
<table align="center" class="word_list_table">
<tr>
<td class="word_list">
<div class="word_row">
<span class="word_num">1:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">2:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">3:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">4:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">5:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">6:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">7:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">8:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">9:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">10:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">11:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">12:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
</td>
<td class="word_list">
<div class="word_row">
<span class="word_num">13:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">14:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">15:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">16:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">17:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">18:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">19:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">20:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">21:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">22:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">23:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
<div class="word_row">
<span class="word_num">24:&nbsp;</span><span class="word_blank">____________________________</span>
</div>
</td></tr></table><table align="center" class="qr_table"><tr rowspan="5"><td class="col_block_divider row_block_divider"></td><td colspan="5" class="col_name col_block_divider">1</td><td colspan="5" class="col_name col_block_divider">2</td><td colspan="5" class="col_name col_block_divider">3</td><td colspan="5" class="col_name col_block_divider">4</td><td colspan="5" class="col_name col_block_divider">5</td><td colspan="5" class="col_name col_block_divider">6</td></tr><tr>
<td rowspan="5" class="row_name row_block_divider">A</td><td id="0_0"></td><td id="0_1"></td><td id="0_2"></td><td id="0_3"></td><td id="0_4"></td><td id="0_5"></td><td id="0_6"></td><td id="0_7"></td><td id="0_8"></td><td id="0_9"></td><td id="0_10"></td><td id="0_11"></td><td id="0_12"></td><td id="0_13"></td><td id="0_14"></td><td id="0_15"></td><td id="0_16"></td><td id="0_17"></td><td id="0_18"></td><td id="0_19"></td><td id="0_20"></td><td id="0_21"></td><td id="0_22"></td><td id="0_23"></td><td id="0_24"></td><td id="0_25"></td><td id="0_26"></td><td id="0_27"></td><td id="0_28"></td></tr>
<tr>
<td id="1_0"></td><td id="1_1"></td><td id="1_2"></td><td id="1_3"></td><td id="1_4"></td><td id="1_5"></td><td id="1_6"></td><td id="1_7"></td><td id="1_8"></td><td id="1_9"></td><td id="1_10"></td><td id="1_11"></td><td id="1_12"></td><td id="1_13"></td><td id="1_14"></td><td id="1_15"></td><td id="1_16"></td><td id="1_17"></td><td id="1_18"></td><td id="1_19"></td><td id="1_20"></td><td id="1_21"></td><td id="1_22"></td><td id="1_23"></td><td id="1_24"></td><td id="1_25"></td><td id="1_26"></td><td id="1_27"></td><td id="1_28"></td></tr>
+2
View File
@@ -0,0 +1,2 @@
[pytest]
testpaths = tests
+1 -1
View File
@@ -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
+26
View File
@@ -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",
)
+5 -1
View File
@@ -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()
+108 -30
View File
@@ -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")
+1
View File
@@ -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 *
+64 -20
View File
@@ -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
+119 -33
View File
@@ -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):
+430
View File
@@ -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)
+1
View File
@@ -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
+62
View File
@@ -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
+2 -2
View File
@@ -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
+112 -44
View File
@@ -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
+2 -2
View File
@@ -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
+14 -11
View File
@@ -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
+680 -9
View File
@@ -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
+3 -2
View File
@@ -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
+2 -2
View File
@@ -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
###
+66 -12
View File
@@ -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)
+26
View File
@@ -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
```
+2
View File
@@ -0,0 +1,2 @@
pytest==6.2.4
mock==4.0.3
+42
View File
@@ -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"
+19
View File
@@ -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()