About the warning message:
Since you are about to match the outputted fingerprint/ID against the proofs at Keybase.io/SeedSigner, and thereby confirm who the pubkey really belongs to-, you can safely ignore this warning message:
@@ -184,16 +185,16 @@ On the *last* output line, look at your *rightmost* 16 characters (the 4 blocks
More about how the verify command works:
-The verify command will attempt to decrypt the signature file (sha256.sig) by trying each public key already imported into your computer. If the public key we just imported (via fetch-keys), manages to: (a) successfully decrypt the .sig file , and (b), that result matches exactly to the clear-text equivalent (.sha256) of the .sig file, then its "a good signature"!
+The verify command will attempt to decrypt the signature file (sha256.sig) by trying each public key already imported into your computer. If the public key we just imported (via fetch-keys), manages to: (a) successfully decrypt the .sig file , and (b), that result matches exactly to the clear-text equivalent (.sha256) of the .sig file, then it's "a good signature"!
-Crucially, we must still manually check who *exactly* owns the Key ID which gave us that "Good signature". Thats what the warning message means- Who does the matching key really belong to? We will start by looking at keybase.io to see if it is "The SeedSigner project"'s public Key or not.
+Crucially, we must still manually check who *exactly* owns the Key ID which gave us that "Good signature". That's what the warning message means- Who does the matching key really belong to? We will start by looking at keybase.io to see if it is "The SeedSigner project"'s public Key or not.
Note that it is the file hashes of .sig and .sha256 that *verify* compares, not their raw contents.
-Now to determine ***who*** the Public key ID belongs to: Goto [Keybase.io/SeedSigner](https://keybase.io/seedsigner)
+Now to determine ***who*** the Public key ID belongs to: Go to [Keybase.io/SeedSigner](https://keybase.io/seedsigner)

@@ -207,13 +208,13 @@ Now to determine ***who*** the Public key ID belongs to: Goto [Keybase.io/SeedSi
Learn more about how keybase.io helps you check that someone (online) is who they say they are:
Keybase.io allows you to independently verify that the public key saved on Keybase.io, is both authentic and that it belongs to the organization it claims to represent.
- Keybase has already checked the three pubkey file locations cryptographically when they were saved there. You can further verify the key publications if you would like:
+ Keybase has already checked the three pubkey file locations cryptographically when they were saved there. You can further verify the key publications if you would like:
- *via Keybase*: By clicking on any of the three blue badges to see that the "proof" was published at that location. (The blue badge marked as tweet, is in the most human-readable form and it is also a bi-directional link on Twitter)
or,
- *without keybase (out-of-band)*: By using these 3 links directly: [Twitter](https://twitter.com/SeedSigner/status/1530555252373704707), [Github](https://gist.github.com/SeedSigner/5936fa1219b07e28a3672385b605b5d2) and [SeedSigner.com](https://seedsigner.com/keybase.txt). This method can be used if you would like to make an even deeper, independent inspection without relying on Keybase at all, or if the Keybase.io site is no longer valid or it is removed entirely.
-Once you have used one of these methods, you will know if the Public Key stored on Keybase, is genuinely from the SeedSinger Project or not.
+Once you have used one of these methods, you will know if the Public Key stored on Keybase, is genuinely from the SeedSigner Project or not.
@@ -224,29 +225,30 @@ If the two ID's do *not* match, then you must stop here immediately. Do not cont
### Step 2. Verifying that the *software images/binaries* are genuine
-Now that you have confirmed that you do have the real SeedSigner Project's Public Key (ie the 16 characters match) - you can return to your terminal window. Running the *shasum* command, is the final verification step and will confirm (via file hashing) that the software code/image files, were also not altered since publication, or even during your download process.
-(Prior to version 0.6.0 , your verify command will check the .zip file which contains the binary files.)
+Now that you have confirmed that you do have the real SeedSigner Project's Public Key (ie the 16 characters match) - you can return to your terminal window. Running the *shasum* command, is the final verification step and will confirm (via file hashing) that the software code/image files, were also not altered since publication, or even during your download process.
+(Prior to version 0.6.0, your verify command will check the .zip file which contains the binary files.)
**On Linux or OSX:** Run this command
```
-shasum -a 256 --ignore-missing --check seedsigner.0.8.5.sha256.txt
+shasum -a 256 --ignore-missing --check seedsigner.0.8.7.sha256.txt
```
+Note: macOS versions earlier than v11 (Big Sur) do not support the `--ignore-missing` flag. You can omit it and disregard any missing file warnings.
**On Windows (inside Powershell):** Run this command
```
-CertUtil -hashfile seedsigner_os.0.8.5.Insert_Your_Pi_Models_binary_here_For_Example_pi02w.img SHA256
+CertUtil -hashfile seedsigner_os.0.8.7.Insert_Your_Pi_Models_binary_here_For_Example_pi02w.img SHA256
```
On Windows, you must then manually compare the resulting file hash value to the corresponding hash value shown inside the .SHA256 cleartext file.
Wait up to 30 seconds for the command to complete, and it should display:
```
-seedsigner_os.0.8.5.[Your_Pi_Model_For_Example:pi02w].img: OK
+seedsigner_os.0.8.7.[Your_Pi_Model_For_Example:pi02w].img: OK
```
-**If you receive the "OK" message** for your **seedsigner_os.0.8.5.[Your_Pi_Model_For_Example:pi02w].img file**, as shown above, then your verification is fully complete!
+**If you receive the "OK" message** for your **seedsigner_os.0.8.7.[Your_Pi_Model_For_Example:pi02w].img file**, as shown above, then your verification is fully complete!
**All of your downloaded files have now been confirmed as both authentic and unaltered!** You can proceed to create/write your MicroSD card😄😄 !!
-If your file result shows "FAILED", then you must stop here immediately. Do not continue. Contact us for assistance at the Telegram group address above.
+If your file result shows "FAILED", then you must stop here immediately. Do not continue. Contact us for assistance at the Telegram group address above.
@@ -265,19 +267,19 @@ To write the SeedSigner software onto your MicroSD card, there are a few options
| DD Command Line Utility | Built-in to Linux and MacOS, the DD (Data Duplicator) is a tool for advanced users. If not used carefully it can accidentally format the incorrect disk! | Built-in to Linux and MacOS |
Be sure to download the software from the genuine publisher.
-Either of the Etcher or Pi Imager software is recommended. Some SeedSigner users have reported a better experience with one or the other. So, if the one application doesn’t work well for your particular machine, then please try the other one.
+Either of the Etcher or Pi Imager software is recommended. Some SeedSigner users have reported a better experience with one or the other. So, if the one application doesn't work well for your particular machine, then please try the other one.
### **General Considerations:**
The writing and verify steps are very quick from version 0.6.0 upwards, so please pay close attention to your screen.
Make sure to set any write-protection physical slider on the MicroSD Card Adapter to UN-locked.
-You also don’t need to pre-format the MicroSD beforehand. You *dont* need to unzip any .zip file beforehand.
-Current Etcher and Pi Imager software will perform a verify action (by default) to make sure the card was written successfully! Watching for that verify step to complete successfully, can save you a lot of headaches if you later need to troubleshoot issues where your SeedSigner device doesn’t boot up at power on.
-Writing the MicroSd card is also known as flashing.
+You also don't need to pre-format the MicroSD beforehand. You *don't* need to unzip any .zip file beforehand.
+Current Etcher and Pi Imager software will perform a verify action (by default) to make sure the card was written successfully! Watching for that verify step to complete successfully, can save you a lot of headaches if you later need to troubleshoot issues where your SeedSigner device doesn't boot up at power on.
+Writing the MicroSD card is also known as flashing.
It will overwrite everything on the MicroSD card.
If the one application fails for you, then please try again using our other recommended application.
-Advanced users may want to try the Linux/MacOS *DD* command instead of using Etcher or Pi Imager, however, a reminder is given that DD can overwrite the wrong disk if you are not careful !
+Advanced users may want to try the Linux/MacOS *DD* command instead of using Etcher or Pi Imager, however, a reminder is given that DD can overwrite the wrong disk if you are not careful!
#### **Specific considerations for Windows users:**
-Use the Pi imager software as your first choice on Windows. Windows can sometimes flag the writing of a MicroSD as risky behaviour and hence it may prevent this activity. If this happens, your writing/flashing will fail, hang or wont even begin, in which case you should to try to run the Etcher/Pi-Imager app "As administrator", (right-click and choose that option). It can also be blocked by windows security in some cases, so If you have the (non-default) *Controlled Folder Access* option set to active, try turning that *off* temporarily.
+Use the Pi imager software as your first choice on Windows. Windows can sometimes flag the writing of a MicroSD as risky behaviour and hence it may prevent this activity. If this happens, your writing/flashing will fail, hang or won't even begin, in which case you should try to run the Etcher/Pi-Imager app "As administrator", (right-click and choose that option). It can also be blocked by windows security in some cases, so If you have the (non-default) *Controlled Folder Access* option set to active, try turning that *off* temporarily.
@@ -288,7 +290,7 @@ Use the Pi imager software as your first choice on Windows. Windows can sometime
### Open Pill
-The Open Pill enclosure design is all about quick, simple and inexpensive depoloyment of a SeedSigner device. The design does not require any additional hardware and can be printed using a standard FDM 3D printer in about 2 hours, no supports necessary. A video demonstrating the assembly process can be found [here](https://youtu.be/gXPFJygZobEa). To access the design file and printable model, click [here](https://github.com/SeedSigner/seedsigner/tree/main/enclosures/open_pill).
+The Open Pill enclosure design is all about quick, simple and inexpensive deployment of a SeedSigner device. The design does not require any additional hardware and can be printed using a standard FDM 3D printer in about 2 hours, no supports necessary. A video demonstrating the assembly process can be found [here](https://youtu.be/gXPFJygZobEa). To access the design file and printable model, click [here](https://github.com/SeedSigner/seedsigner/tree/main/enclosures/open_pill).
### Orange Pill
diff --git a/docs/code_structure.md b/docs/code_structure.md
index 50b7d315..5a0f0276 100644
--- a/docs/code_structure.md
+++ b/docs/code_structure.md
@@ -1,21 +1,17 @@
# Code Structure
-SeedSigner roughly follows a Model-View-Controller approach. Like in a typical web app (e.g. Flask) the `View`s can be called as needed like individual web urls. After completing display and interaction with the user, the `View` then decides where to route the user next, analogous to a web app returning a `response.redirect(url)`.
-
-The `Controller` then ends up being quite stripped down. For example, there's no need for a web app's `urls.py` since there are no mappings from url to `View` to maintain since we're not actually using a url/http routing approach.
-
-`View`s have to handle user interaction so there are `while True` loops that cycle between waiting for user input, gathering data, and then updating the UI components accordingly. You wouldn't find this kind of cycle in a web app because this sort of interactive user input is handled in the browser at the html/css/js level.
+SeedSigner roughly follows a Model-View-Controller approach. Like in a typical web app (e.g. Flask), the `View`s can be called as needed like individual web URLs. After completing display and interaction with the user, the `View` then decides where to route the user next, analogous to a web app returning a `response.redirect(URL)`.
+The `Controller` then ends up being quite stripped down. For example, there's no need for a web app's `urls.py` since there are no mappings from URL to `View` to maintain since we're not actually using a URL/HTTP routing approach.
+`View`s have to handle user interaction, so there are `while True` loops that cycle between waiting for user input, gathering data, and then updating the UI components accordingly. You wouldn't find this kind of cycle in a web app because this sort of interactive user input is handled in the browser at the HTML/CSS/JS level.
* `Model`s: Store the persistent settings, the in-memory seeds, current wallet information, etc.
* `Controller`: Manages the state of the world and controls access to global resources.
* `View`s: Implementation of each screen. Prepares relevant data for display. Must also instantiate the display objects that will actually render the UI.
-* `gui.screens`: Re-usable formatted UI renderers.
+* `gui.screens`: Reusable formatted UI renderers.
* `gui.components`: Basic individual UI elements that are used by the `templates` such as the top nav, buttons, button lists, text displays.
-In an typical webserver context the `View` would send data to an html template (e.g. Jinja) which would then dynamically populate the page with html elements like ` `, ``, ` `, etc. This is analgous to our `gui.screens` constructing a UI renderer by piecing together various `gui.components` as needed.
-
-
+In a typical web server context, the `View` would send data to an HTML template (e.g. Jinja) which would then dynamically populate the page with HTML elements like ` `, ``, ` `, etc. This is analogous to our `gui.screens` constructing a UI renderer by piecing together various `gui.components` as needed.
`Controller` is a global singleton that any `View` can access and update as needed.
diff --git a/docs/debug_crash.md b/docs/debug_crash.md
index b53b6b51..1f926f69 100644
--- a/docs/debug_crash.md
+++ b/docs/debug_crash.md
@@ -1,37 +1,45 @@
-## Debugging a Crash for advanced (technical) users
+## Debugging a Crash for Advanced (Technical) Users
These instructions are intended to help users of SeedSigner provide crash exception and traceback logs to developers to aid in troubleshooting and resolving bugs.
### Testnet vs Mainnet
-Whenever possible recreate a crash in testnet. This will help avoid accidently revealing private information about yourself, your bitcoin transactions, or lose any funds.
+Whenever possible, recreate a crash in testnet. This will help avoid accidentally revealing private information about yourself, your Bitcoin transactions, or losing any funds.
-### Network connected SeedSigner
+### Network-Connected SeedSigner
-If you are using SeedSigner for development and testing, then I recommend network access via ssh to view crash logs. Follow [these](https://github.com/SeedSigner/seedsigner/blob/main/docs/usb_relay.md) instructions to setup a USB relay for internet access. You can also connect your SeedSigner to Wifi if you have a rasp pi zero w/ wifi.
+If you are using SeedSigner for development and testing, then we recommend network access via SSH to view crash logs. Follow [these](https://github.com/SeedSigner/seedsigner/blob/main/docs/usb_relay.md) instructions to set up a USB relay for internet access. You can also connect your SeedSigner to WiFi if you have a Raspberry Pi Zero W with WiFi.
-### Airgapped debugging setup
+### Airgapped Debugging Setup
-If you are using SeedSigner for mainnet transactions, then do not connect your device to a network or the internet. Instead connect your SeedSigner to a HDMI display (without internet) and a USB keyboard. This will require an HDMI adapter and micro USB to USB A adapter. Plug in the HDMI display and keyboard before powering on SeedSigner. The password for the SeedSigner pi user is `raspberry`.
+If you are using SeedSigner for mainnet transactions, then do not connect your device to a network or the internet. Instead, connect your SeedSigner to an HDMI display (without internet) and a USB keyboard. This will require an HDMI adapter and a micro USB to USB A adapter. Plug in the HDMI display and keyboard before powering on SeedSigner. The password for the SeedSigner pi user is `raspberry`.
-### Debugging steps
+### Debugging Steps
-At this point you should be signed into the pi user either on a HDMI display (via command line) or a ssh connection.
+At this point, you should be signed into the pi user either on an HDMI display (via command line) or an SSH connection.
-Follow these steps to setup a debug session.
+Follow these steps to set up a debug session:
-`cd seedsigner/src`
+1. Navigate to the source directory:
+ ```bash
+ cd seedsigner/src
+ ```
-`nano settings.ini`
+2. Edit the settings file:
+ ```bash
+ nano settings.ini
+ ```
-in nano editor change `debug = False` to `debug = True` (case sensitive). Save and exit settings.ini.
+3. In the nano editor, change `debug = False` to `debug = True` (case sensitive). Save and exit settings.ini.
-stop the seedsigner systemd process by running
+4. Stop the SeedSigner systemd process:
+ ```bash
+ sudo systemctl stop seedsigner.service
+ ```
-`sudo systemctl stop seedsigner.service`
-
-now start the python app manually by running
-
-`python3 main.py`
+5. Start the Python app manually:
+ ```bash
+ python3 main.py
+ ```
SeedSigner should now be up and running. Keep it connected to the display and keyboard. Recreate the steps to cause the crash. The traceback log and exception will be displayed on the HDMI display.
\ No newline at end of file
diff --git a/docs/developer_tips.md b/docs/developer_tips.md
index b068e1db..4206a853 100644
--- a/docs/developer_tips.md
+++ b/docs/developer_tips.md
@@ -3,12 +3,16 @@
### Quickly generate a new seed to test with
Generate a new 12- or 24-word seed via [https://iancoleman.io/bip39/](https://iancoleman.io/bip39/).
-Access a `python3` environment that has the `embit` library installed (e.g. your own local machine, ssh into the SeedSigner, etc)
+Access a `python3` environment that has the `embit` library installed (e.g., your own local machine, SSH into the SeedSigner, etc.).
-Start a python REPL session by just typing: `python3`
-
-Paste in the following but insert your newly generated mnemonic:
+Start a `python3` REPL session by typing:
+```bash
+python3
```
+
+Paste in the following code (replace the seed phrase with your newly generated mnemonic):
+
+```python
from embit import bip39
seed_phrase = "smoke chimney announce candy glory tongue refuse fatigue cricket once consider beef treat urge wing deny gym robot tobacco adult problem priority wheat diagram"
data = ""
@@ -19,7 +23,7 @@ for word in seed_phrase.split(" "):
print(data)
```
-For the seed in the snippet, you should see:
+For the example seed phrase above, you should see:
```
163803200074026607961827144306700411123603780160185419152013046908321497181700301371136719990487
```
@@ -28,20 +32,20 @@ Take the output and paste it into a [QR code generator](https://www.the-qrcode-g
Start up SeedSigner's UI to import a seed from a QR code. Scan the new QR code and you're good to go!
-
# Advanced developer notes
## Backup an SD card
-You can back up and restore any size SD card but the process is a little inefficient so the bigger the source SD card, the bigger the backup will be and the longer it'll take to create (and even longer to image back to a new SD card), even if most of the card is blank.
+You can back up and restore any size SD card, but the process is a little inefficient. The bigger the source SD card, the bigger the backup will be and the longer it'll take to create (and even longer to image back to a new SD card), even if most of the card is blank.
-You can restore a backup image to an SD card of the same or larger size. So it's strongly recommended to do repetitive development work on a smaller card that's easier to backup and restore. Once the image is stabilized, then write it to a bigger card, if necessary (that being said, there's really no reason to use a large SD card for SeedSigner. An 8GB SD card is more than big enough).
+You can restore a backup image to an SD card of the same or larger size. It's strongly recommended to do repetitive development work on a smaller card that's easier to backup and restore. Once the image is stabilized, then write it to a bigger card, if necessary (that being said, there's really no reason to use a large SD card for SeedSigner. An 8GB SD card is more than big enough).
-Insert the SD card into a Mac/Linux machine and create a compressed img file.
+### Steps to create a backup:
-First verify the name of your SD card:
+1. Insert the SD card into a Mac/Linux machine
+2. Verify the name of your SD card:
-```
+```bash
# Mac:
diskutil list
@@ -49,14 +53,15 @@ diskutil list
sudo fdisk -l
```
-It will most likely be `/dev/disk1` on most systems.
+The device will most likely be `/dev/disk1` on most systems.
-Now we use `dd` to clone and `gzip` to compress it. Note that we reference the SD card by adding an `r` in front of the disk name. This speeds up the cloning considerably.
+3. Create a compressed backup using `dd` and `gzip`. Note that we reference the SD card by adding an `r` in front of the disk name to speed up the cloning process:
-```
+```bash
sudo dd if=/dev/rdisk1 conv=sparse bs=4m | gzip -9 > seedsigner.img.gz
```
The process should take about 15 minutes and will typically generate a roughly 1.1GB image.
-To restore from your backup image, just use the Raspberry Pi Imager. Remember that you can only write it to an SD card of equal or greater size than the original SD card.
+### Restoring from backup
+To restore from your backup image, use the Raspberry Pi Imager. Remember that you can only write it to an SD card of equal or greater size than the original SD card.
\ No newline at end of file
diff --git a/docs/dice_verification.md b/docs/dice_verification.md
index 7e3833b4..078a3889 100644
--- a/docs/dice_verification.md
+++ b/docs/dice_verification.md
@@ -7,20 +7,19 @@ As usual: Don't Trust, Verify!
**Note:**
**Do NOT use this with any seed you want to use later with real funds. This exercise is only for checking that the independent codebases get to the same end result!**
-**However, if you do want to check your real seedphrases you should download the Iancoleman and/or Bitcoiner.Guide tools onto an airgapped, ephemeral computer (e.g. using tails-OS) and perform these tests on there. Destroy/abandon the TailsOS afterwards.**
+**However, if you do want to check your real seed phrases you should download the Ian Coleman and/or Bitcoiner.Guide tools onto an airgapped, ephemeral computer (e.g. using Tails-OS) and perform these tests on there. Destroy/abandon the Tails-OS afterwards.**
**Never input seed phrases that you intend to use to store real funds onto an internet-connected computer!!!**
## 99 Dice Rolls / 24 Seed Words Example
-The following 99 dice roll results are used in the verification steps as an example for a 24 words seed:
+The following 99 dice roll results are used in the verification steps as an example for a 24-word seed:
> 655152231316521321611331544441236164664431121534415633526456254462245546236542364246312613322234612
The corresponding 24 seed words are:
> eyebrow obvious such suggest poet seven breeze blame virtual frown dynamic donor harsh pigeon express broccoli easy apology scatter force recipe shadow claim radio
(Scroll down near the end to see result values for a 50 dice rolls / 12 seed words example)
-
## Creating seed via Dice rolls in SeedSigner (here v0.6.0)
@@ -56,7 +55,7 @@ Keep the SeedSigner open and the newly created seed still loaded as we will need
## Create new wallet from seed in Sparrow Wallet to see xpub/zpub and addresses
Go to https://www.sparrowwallet.com/download/ and download the release version supported by your operating system.
-Open Sparrow Wallet, go to 'File' menu and select 'New Wallet'. Enter a name (e.g. test), and click 'Create Wallet'.
+Open Sparrow Wallet, go to the 'File' menu and select 'New Wallet'. Enter a name (e.g. test), and click 'Create Wallet'.
Click 'Airgapped Hardware Wallet' (1) and click on the 'Scan' button in the SeedSigner entry (2) which will open the camera scan screen:
@@ -84,7 +83,7 @@ Go to https://iancoleman.io/bip39 and check 'Show entropy details' (1):
Make sure to check (1) 'Hex' and (2) '24 Words' as 'Mnemonic Length'.
(Do not use 'dice' format because dice 6 will be replaced by 0).
-Then enter the 99 dices numbers in (3). The corresponding seed words are shown in (4):
+Then enter the 99 dice numbers in (3). The corresponding seed words are shown in (4):
The 24 seed words are the same in SeedSigner and the Ian Coleman tool.
@@ -144,7 +143,7 @@ Compare to zpub in Sparrow:
-Zpub is the same as shown in SeedSigner, Sparrow and Ian Colemand tool.
+Zpub is the same as shown in SeedSigner, Sparrow and Ian Coleman tool.
**Addresses:**
Scroll down a little bit where the receive addresses are shown and compare to the ones generated in Sparrow ('Addresses' tab of the wallet):
@@ -163,10 +162,10 @@ Check that the change addresses all match.
## 50 Dice Rolls / 12 Seed Words Example
-SeedSigner supports the creation of mnenomic seeds both with 12 or 24 seed words corresponding to 50 or 99 dice rolls. Below are some example result values for using 50 dice rolls only.
-All the steps shown can be executed the same way, just select the '12 words (50 rolls)' option in SeedSigner and change the 'Mnenomic Length' dropdown boxes in both web tools to '12 Words'.
+SeedSigner supports the creation of mnemonic seeds both with 12 or 24 seed words corresponding to 50 or 99 dice rolls. Below are some example result values for using 50 dice rolls only.
+All the steps shown can be executed the same way, just select the '12 words (50 rolls)' option in SeedSigner and change the 'Mnemonic Length' dropdown boxes in both web tools to '12 Words'.
-50 dice roll results as an example for a 12 words seed:
+50 dice roll results as an example for a 12-word seed:
> 65515223131652132161133154444123616466443112153441
The corresponding 12 seed words are:
@@ -246,7 +245,7 @@ python3 mnemonic.py -h
python3 mnemonic.py coins 1111100111...
# 256 coin flips / 24-word mnemonic
- python mnemonic.py coins 0010111010...
+ python3 mnemonic.py coins 0010111010...
# GENERATE 50 random dice rolls / 12-word mnemonic
python3 mnemonic.py dice rand12
diff --git a/docs/electrum.md b/docs/electrum.md
index 6c07ca31..064065cd 100644
--- a/docs/electrum.md
+++ b/docs/electrum.md
@@ -1,8 +1,8 @@
-# SeedSigner Electrum seed phrase support
+# SeedSigner Electrum Seed Phrase Support
-SeedSigner supports loading of [Electrum's Segwit seed phrases](https://electrum.readthedocs.io/en/latest/seedphrase.html#electrum-seed-version-system). This is considered an Advanced feature that is disabled by default.
+SeedSigner supports loading of [Electrum's SegWit seed phrases](https://electrum.readthedocs.io/en/latest/seedphrase.html#electrum-seed-version-system). This is considered an Advanced feature that is disabled by default.
-To load an Electrum Segwit seed phrase, first enable Electrum seed support in Settings -> Advanced -> Electrum seed support. After this option is enabled, the user will now be able to enter an Electrum seed phrase by selecting "Enter Electrum seed" in the Load Seed screen.
+To load an Electrum SegWit seed phrase, first enable Electrum seed support in Settings -> Advanced -> Electrum seed support. After this option is enabled, the user will now be able to enter an Electrum seed phrase by selecting "Enter Electrum seed" in the Load Seed screen.
Some SeedSigner functionality is deliberately disabled when using an Electrum mnemonic:
@@ -11,5 +11,5 @@ Some SeedSigner functionality is deliberately disabled when using an Electrum mn
- SeedQR backups
- Since Electrum seeds are not supported by other SeedQR implementations, it would be dangerous to use SeedQR as a backup tool for Electrum seeds and is thus disabled
- Custom derivations
- - Hard coded derivation path and script types in SeedSigner to match Electrum wallet software. These are m/0h for single sig and m/1h for multisig
+ - Hard-coded derivation path and script types in SeedSigner to match Electrum wallet software. These are m/0h for single sig and m/1h for multisig
- User-chosen custom derivations are thus not supported for Electrum seeds
diff --git a/docs/feature_roadmap.md b/docs/feature_roadmap.md
index 2406eb18..b86b1a82 100644
--- a/docs/feature_roadmap.md
+++ b/docs/feature_roadmap.md
@@ -6,28 +6,28 @@ Current focus: v0.5.0 preview releases.
## v0.5.0 Pre-Release 1.x
-* Scan SeedQR / CompactSeedQR
+* Scan SeedQR/CompactSeedQR
* Add/Edit passphrase
-* View seed words w/configurable warnings
-* Export xpub w/configurable warnings and flow determined by Settings
+* View seed words with configurable warnings
+* Export xpub with configurable warnings and flow determined by Settings
* Scan PSBT
* Full PSBT review screens
* "Full Spend" (no change) warning
-* Fully verify PSBT change addrs
+* Fully verify PSBT change addresses
* Send signed PSBT via QR
* QR display dimming/brightness UP/DOWN
* Subset of configurable Settings; persistent Settings storage
* SettingsQR integration proof-of-concept
-Screens will be functional but not necessarily in their final presentation state (icons, text, positioning, etc).
+Screens will be functional but not necessarily in their final presentation state (icons, text, positioning, etc.).
## v0.5.0 Pre-Release 2.x
-* Existing screen refinement (visual presentation, text, etc)
+* Existing screen refinement (visual presentation, text, etc.)
* Create new seed via image entropy
* Manual mnemonic seed word entry
-* 12th/24th word calc
-* SeedQR/CompactSeedQR manual transcription UI w/configurable UI style (dots vs grid)
+* 12th/24th word calculation
+* SeedQR/CompactSeedQR manual transcription UI with configurable UI style (dots vs grid)
* Single sig address scan and verification
* SettingsQR standalone UI refinement
* Fix broken tests
@@ -52,16 +52,16 @@ All of the above!
## Beyond v0.5.0
These features will not be included in the initial v0.5.0 release and will have varying degrees of priority for subsequent releases (or possibly not at all).
-* Multisig wallet descriptor QR scan(?) and addr verification(?)
-* Sign taproot txs
+* Multisig wallet descriptor QR scan(?) and address verification(?)
+* Sign taproot transactions
* Multi-language support (Transifex free for open source projects)
-* Multisig: sign PSBT with multiple keys at once.
+* Multisig: sign PSBT with multiple keys at once
* Custom OS, possibly with swappable SD card PSBT and multisig wallet descriptor storage
* Decoy game mode at launch (Snake, Tetris, Sudoku...?)
* BIP-39 wordlists in additional languages
* Address message signing
* UI color scheme customization
-* Specify missing entropy for 12th/24th word calc
+* Specify missing entropy for 12th/24th word calculation
# v0.6 and Beyond...?
diff --git a/docs/legacy_hardware.md b/docs/legacy_hardware.md
index 09daaaab..8f001d2d 100644
--- a/docs/legacy_hardware.md
+++ b/docs/legacy_hardware.md
@@ -5,18 +5,18 @@ Older Raspberry Pi devices have a smaller GPIO header, 26 pin as opposed to the
## Hardware Changes
A suggested remapping can be found here:
-
+
This remapping can be done by soldering wires on to the Waveshare hat as below:

-Alternatively, you could do this by connecting the LCD hat on with individual breadboard jumper wires.
+Alternatively, you could do this by connecting the LCD hat with individual breadboard jumper wires.
-Once you have re-mapped the pins, it is advised to do an IO Test to ensure that everything works.
+Once you have re-mapped the pins, it is advised to do an I/O Test to ensure that everything works.
-**Warning: Some of the GPIO pins on contain 5 volt output. Raspberry Pi GPIO pins are NOT 5V tolerant, meaning that if you accidentally connect a 5V supply pin to a GPIO input pin, you risk permanent damage.**
+**Warning: Some of the GPIO pins contain 5 volt output. Raspberry Pi GPIO pins are NOT 5V tolerant, meaning that if you accidentally connect a 5V supply pin to a GPIO input pin, you risk permanent damage.**
## Software Changes
-The Seedsigner software will automatically detect which hardware revision you are using and if the older hardware is detected, will remap the software to match the above modifications to the Waveshare hat.
+The SeedSigner software will automatically detect which hardware revision you are using and if the older hardware is detected, will remap the software to match the above modifications to the Waveshare hat.
-If you are using a pre-built Seedsigner image that hasn't yet has this incorporated, you can simply take the file "buttons.py" (/src/seedsigner/hardware/ int his repository) and overwrite same file on your Seedsigner SD card. (The easiest way to do this is to copy it on to the /boot/ partitition of the SD card then copy it over via the command line while connected to your Pi via monitor+keyboard) O
\ No newline at end of file
+If you are using a pre-built SeedSigner image that hasn't yet had this incorporated, you can simply take the file "buttons.py" (/src/seedsigner/hardware/ in this repository) and overwrite the same file on your SeedSigner SD card. (The easiest way to do this is to copy it to the /boot/ partition of the SD card then copy it over via the command line while connected to your Pi via monitor+keyboard.)
\ No newline at end of file
diff --git a/docs/qr_formats.md b/docs/qr_formats.md
index b0fd4224..b89038f8 100644
--- a/docs/qr_formats.md
+++ b/docs/qr_formats.md
@@ -2,7 +2,7 @@
### Scanning QR Codes
-SeedSigner supports scanning the following QR formats
+SeedSigner supports scanning the following QR formats:
Animated QR Formats:
- PSBT
@@ -15,22 +15,22 @@ Static QR Formats:
- Base64 (if the PSBT byte size is too large, SS may have trouble scanning)
- Seed
- [SeedSigner SeedQR](seed_qr/README.md) format
- - A 48 or 96 length string of numbers representing a bip39 wordlist (all wordlist languages supported). The numeric sequence is a concatenation of four-digit, zero-padded segments. Each four-digit segment represents a bip39 word expressed by a zero-indexed position in the wordlist. For example "0000" is abandon in the english bip39 wordlist.
+ - A 48 or 96 length string of numbers representing a BIP-39 wordlist (all wordlist languages supported). The numeric sequence is a concatenation of four-digit, zero-padded segments. Each four-digit segment represents a BIP-39 word expressed by a zero-indexed position in the wordlist. For example, "0000" is "abandon" in the English BIP-39 wordlist.
- [SeedSigner CompactSeedQR](seed_qr/README.md) format
- - The 128- or 256-bit entropy encoded as a binary QR.
- - English Bip39 Mnemonic words separated by a space. Currently only supports 12 and 24 word seeds.
- - English Bip39 Mnemonic with only first 4 letters seperated by a space. Currently only supports 12 and 24 word seeds.
+ - The 128- or 256-bit entropy encoded as a binary QR
+ - English BIP-39 Mnemonic words separated by a space (currently only supports 12 and 24 word seeds)
+ - English BIP-39 Mnemonic with only first 4 letters separated by a space (currently only supports 12 and 24 word seeds)
### Displaying QR Codes
-SeedSigner supports displaying QR's in the following formats
+SeedSigner supports displaying QR codes in the following formats:
Animated QR Formats:
- PSBT
- Blockchain Commons [UR2 PSBT](https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-006-urtypes.md)
- - Specter Desktop base64 segments animated
+ - Specter Desktop base64 segments animated
Static QR Formats:
- Seed
- SeedSigner Seed QR format
- - A 48 or 96 length string of numbers representing a bip39 wordlist (all wordlist languages supported). The numeric sequence is a concatination of four-digit zero padded segments. Each four-digit segment represents a bip39 word expressed by a zero-indexed position in the wordlist. For example "0000" is abandon in the english bip39 wordlist.
+ - A 48 or 96 length string of numbers representing a BIP-39 wordlist (all wordlist languages supported). The numeric sequence is a concatenation of four-digit, zero-padded segments. Each four-digit segment represents a BIP-39 word expressed by a zero-indexed position in the wordlist. For example, "0000" is "abandon" in the English BIP-39 wordlist.
diff --git a/docs/raspberry_pi_os_build_instructions.md b/docs/raspberry_pi_os_build_instructions.md
index b1102a38..9d4f9acd 100644
--- a/docs/raspberry_pi_os_build_instructions.md
+++ b/docs/raspberry_pi_os_build_instructions.md
@@ -1,23 +1,23 @@
# Raspberry Pi OS Local Dev Build Instructions
-Since v0.6.0, official releases use our custom [SeedSigner OS](https://github.com/SeedSigner/seedsigner-os/) However, project contributors looking to do rapid development cycles typically use the older Raspberry Pi OS that we had previously built on prior to v0.6.0. If you're here to set up your SeedSigner for local development, continue reading.
+Since v0.6.0, official releases use our custom [SeedSigner OS](https://github.com/SeedSigner/seedsigner-os/). However, project contributors looking to do rapid development cycles typically use the older Raspberry Pi OS that we had previously built on prior to v0.6.0. If you're here to set up your SeedSigner for local development, continue reading.
-Begin by acquiring the latest 32-bit, Buster-based Raspberry Pi Lite operating system. This guide was tested using the version dated 2023-05-03; which can be found here:
+Begin by acquiring the latest 32-bit, Buster-based Raspberry Pi Lite operating system. This guide was tested using the version dated 2023-05-03, which can be found here:
https://downloads.raspberrypi.org/raspios_oldstable_lite_armhf/images/raspios_oldstable_lite_armhf-2023-05-03/
-SeedSigner does not work any of the more recent versions of Debian. This is a known limitation and there are open tickets to track the progress of this ([Debian 11 ticket](https://github.com/SeedSigner/seedsigner/issues/431), [Debian 12 ticket](https://github.com/SeedSigner/seedsigner/issues/430)). This guide does not work on the 64-bit versions of Buster, however pull requests to update it to be compatible are welcome.
+SeedSigner does not work with any of the more recent versions of Debian. This is a known limitation and there are open tickets to track the progress of this ([Debian 11 ticket](https://github.com/SeedSigner/seedsigner/issues/431), [Debian 12 ticket](https://github.com/SeedSigner/seedsigner/issues/430)). This guide does not work on the 64-bit versions of Buster, however pull requests to update it to be compatible are welcome.
Best practice is to verify the downloaded file containing the Raspberry Pi Lite OS matches the published SHA256 hash of the file; for additional reference that hash is: 3d210e61b057de4de90eadb46e28837585a9b24247c221998f5bead04f88624c. After verifying the file's data integrity, you can decompress the .tar.xz file to obtain the operating system image that it contains. You can then use Balena's Etcher tool (https://www.balena.io/etcher/) to write the Raspberry Pi Lite software image to a memory card (4 GB or larger). It's important to note that an image authoring tool must be used (the operating system image cannot be simply copied into a file storage partition on the memory card).
The manual SeedSigner installation and configuration process requires an internet connection on the Pi to download the necessary libraries and code.
-If your Pi does not have onboard wifi, you have two options:
+If your Pi does not have onboard WiFi, you have two options:
-1. Run these steps on a separate Raspberry Pi 2/3/4 or Zero W which does have onboard Wi-Fi to connect to the internet, and then move the SD card over to the non Wi-Fi enabled Pi when complete.
-2. OR configure the non Wi-Fi enabled Pi directly by relaying through your computer's internet connection over USB. See instructions [here](usb_relay.md).
+1. Run these steps on a separate Raspberry Pi 2/3/4 or Zero W which does have onboard WiFi to connect to the internet, and then move the SD card over to the non WiFi enabled Pi when complete.
+2. OR configure the non WiFi enabled Pi directly by relaying through your computer's internet connection over USB. See instructions [here](usb_relay.md).
-If your Pi does have onboard Wi-Fi, then using the Rasberry Pi Imager software will allow you to easily configure your Pi's Wi-Fi connection, as well as simultaneously write the image file. That will make your initial SSH into the Pi much easier.
-Use the Pi's onboard Wi-Fi only if you are setting up a local development environment, never for real funds or binary image creation.
+If your Pi does have onboard WiFi, then using the Raspberry Pi Imager software will allow you to easily configure your Pi's WiFi connection, as well as simultaneously write the image file. That will make your initial SSH into the Pi much easier.
+Use the Pi's onboard WiFi only if you are setting up a local development environment, never for real funds or binary image creation.
For the following steps you'll need to either connect a keyboard & monitor to the network-connected Raspberry Pi you are working with, or SSH into the Pi if you're familiar with that process.
@@ -60,8 +60,7 @@ Set the following:
When you exit the System Configuration tool, you will be prompted to reboot the system; allow the system to reboot and continue with these instructions.
-
-Each command should be run individually,unless its specified as a multi-line command.
+Each command should be run individually, unless it's specified as a multi-line command.
### Change the default password
Change the system's default password from the default "raspberry". Run the command:
```bash
@@ -69,7 +68,6 @@ passwd
```
You will be prompted to enter the current password ("raspberry") and then to enter a new password twice. In our prepared release image, the password used is `AirG@pped!`.
-
### Install python3.10
```bash
# install compiler dependencies; takes ~1 minute on a Pi Zero 1.3
@@ -77,7 +75,7 @@ You will be prompted to enter the current password ("raspberry") and then to ent
# * libsqlite3-dev: required by `coverage`
sudo apt update && sudo apt install -y build-essential zlib1g-dev \
libncurses5-dev libgdbm-dev libnss3-dev openssl libssl-dev \
- libreadline-dev libffi-dev wget libsqlite3-dev
+ libreadline-dev libffi-dev wget libsqlite3-dev libraqm-dev
# Grab the python3.10 source
wget https://www.python.org/ftp/python/3.10.10/Python-3.10.10.tgz
@@ -106,7 +104,6 @@ sudo apt autoremove -y
sudo apt install python3-apt -y
```
-
### Install dependencies
Copy this entire box and run it as one command (~15 minutes on a Pi Zero 1.3):
```bash
@@ -189,7 +186,7 @@ The fork is required because the main `pyzbar` repo has been abandoned. This [gi
### Optional: increase spidev buffer size
-This allows `ST7789.py` to update the LCD without performing multiple write operations because the default buffer size is 4096 bytes. The default can be changed via the `/boot/cmdline.txt` file. You will need to add `spidev.bufsiz=131072` to the end of this single lined file command.
+This allows `ST7789.py` to update the LCD without performing multiple write operations because the default buffer size is 4096 bytes. The default can be changed via the `/boot/cmdline.txt` file. You will need to add `spidev.bufsiz=131072` to the end of this single lined file command.
Example `cmdline.txt` contents:
```
@@ -203,7 +200,7 @@ sudo nano /etc/systemd/system/seedsigner.service
```
Add the following contents to the text file that was created:
-If you are not using the username pi, then replace `pi` in the service section below with your username. There are 3 lines to change.
+If you are not using the username pi, then replace `pi` in the service section below with your username. There are 3 lines to change.
```ini
[Unit]
Description=Seedsigner
@@ -214,19 +211,19 @@ WorkingDirectory=/home/pi/seedsigner/src/
ExecStart=/usr/bin/python3 main.py
StandardOutput=null
ErrorOutput=null
-Restart=always
+Restart=no
[Install]
WantedBy=multi-user.target
```
-_Note: For local dev you'll want to edit the `Restart=always` line to `Restart=no`. This way when your dev code crashes it won't keep trying to restart itself. Note that the UI "Reset" will no longer work when auto-restarts are disabled._
+_Note: The line `Restart=no` ensures that when your dev code crashes it won't keep trying to restart itself._
_Note: Debugging output is completely wiped via routing the stdout and stderr to `/dev/null`. When working in local dev, you'll `kill` the `systemd` SeedSigner service and just directly run the code on demand so you can see all the debugging output live._
Use `CTRL-X` and `y` to exit and save changes.
-Configure the service to start running (this will restart the seedsigner code automatically at startup and if it crashes):
+Configure the service to start running (this will restart the seedsigner code automatically at startup):
```bash
sudo systemctl enable seedsigner.service
```
@@ -313,9 +310,9 @@ First find your current `nameserver`:
sudo cat /etc/resolv.conf
```
-This is the address of your local machine that is connected to your SeedSigner via usb (or it'll be the wifi router's address if you're using a Raspi with wifi and are keeping it enabled for `ssh` access).
+This is the address of your local machine that is connected to your SeedSigner via USB (or it'll be the WiFi router's address if you're using a Raspberry Pi with WiFi and are keeping it enabled for `ssh` access).
-Set a static ip: `sudo nano /etc/dhcpcd.conf` and add to the end:
+Set a static IP: `sudo nano /etc/dhcpcd.conf` and add to the end:
```
interface usb0
static ip_address=192.168.1.200/24
@@ -323,16 +320,16 @@ static routers=192.168.1.254
static domain_name_servers=192.168.1.254
```
-* `interface` will be `usb0` for usb connections; `wlan0` for wifi.
-* `static ip_address` is the ip address you want the SeedSigner to use. It should match the `nameserver` ip you found above for all but the last part of the ip (note: the `/24` should always be included as-is).
-* `static routers` should be your `nameserver` ip.
-* `static domain_name_servers` should also be the `nameserver` ip.
+* `interface` will be `usb0` for USB connections; `wlan0` for WiFi.
+* `static ip_address` is the IP address you want the SeedSigner to use. It should match the `nameserver` IP you found above for all but the last part of the IP (note: the `/24` should always be included as-is).
+* `static routers` should be your `nameserver` IP.
+* `static domain_name_servers` should also be the `nameserver` IP.
`CTRL-X` and `y` to save changes.
-After your next reboot, access this SeedSigner using its new static ip:
+After your next reboot, access this SeedSigner using its new static IP:
```bash
-# Use the static ip you set above:
+# Use the static IP you set above:
ssh pi@192.168.1.200
# But the hostname will still work, too:
@@ -350,7 +347,7 @@ host seedsigner.local
User pi
LogLevel QUIET
-# Set this to the static ip you set above:
+# Set this to the static IP you set above:
host 192.168.1.200
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
@@ -360,7 +357,7 @@ host 192.168.1.200
The first entry prevents warnings for the default `pi@seedsigner.local` connections.
-The second entry does the same for a specific static ip; you'll want this if you configure all your SeedSigners to use the same static ip.
+The second entry does the same for a specific static IP; you'll want this if you configure all your SeedSigners to use the same static IP.
`CTRL-X` and `y` to save changes.
@@ -372,7 +369,7 @@ run `ssh-copy-id` with the same values that you connect via `ssh`:
```bash
ssh-copy-id pi@seedsigner.local
-# or if you're connecting over static ip, something like:
+# or if you're connecting over static IP, something like:
ssh-copy-id pi@192.168.1.200
```
@@ -381,8 +378,8 @@ You'll be prompted to enter the password to complete it.
_Note: If you don't have any ssh keys on your local machine, you'll need to create a set with `ssh-keygen -t ed25519 -C "your_email@example.com"`. Then try running `ssh-copy-id` again._
-## Disable wifi/Bluetooth when using other Raspi boards
-If you plan to use your installation on a Raspberry Pi that is not a Zero version 1.3, but rather on a Raspberry Pi that has WiFi and Bluetooth capabilities, it is a good idea to disable the following WiFi & Bluetooth, as well as other relevant services (assuming you are not creating this installation for testing/development purposes). Enter the followiing commands to disable WiFi, Bluetooth, & other relevant services:
+## Disable WiFi/Bluetooth when using other Raspberry Pi boards
+If you plan to use your installation on a Raspberry Pi that is not a Zero version 1.3, but rather on a Raspberry Pi that has WiFi and Bluetooth capabilities, it is a good idea to disable the following WiFi & Bluetooth, as well as other relevant services (assuming you are not creating this installation for testing/development purposes). Enter the following commands to disable WiFi, Bluetooth, & other relevant services:
```bash
sudo systemctl disable bluetooth.service
sudo systemctl disable wpa_supplicant.service
@@ -409,4 +406,4 @@ Please remember that it can take up to a minute for the GUI to appear when power
### Optional: Run the tests
-see: [tests/README.md](../tests/README.md)
+see: [tests/README.md](../tests/README.md)
\ No newline at end of file
diff --git a/docs/seed_qr/README.md b/docs/seed_qr/README.md
index 40beaf8d..ef34a882 100644
--- a/docs/seed_qr/README.md
+++ b/docs/seed_qr/README.md
@@ -2,7 +2,7 @@
[SeedSigner](https://github.com/SeedSigner/seedsigner/) is an open source, DIY, fully-airgapped Bitcoin hardware wallet that wipes all private data from memory each time it's turned off. That means users need to re-enter their mnemonic seed phrase each time they use it.
-To speed up this key entry process we have defined a way to encode a BIP-39 mnemonic seed phrase as a QR code that can be instantly scanned into a SeedSigner or potentially any other Bitcoin hardware wallet that has a camera.
+To speed up this key entry process, we have defined a way to encode a BIP-39 mnemonic seed phrase as a QR code that can be instantly scanned into a SeedSigner or potentially any other Bitcoin hardware wallet that has a camera.
The approach is specifically designed to encode the minimum possible amount of data in order to keep the resulting QR code small enough that it can be transcribed *by hand*. This sounds ridiculous at first, but remember that this is secret data that should never be stored in any digital medium. And even printers present some additional risk vectors.
@@ -22,13 +22,15 @@ Specifications for each follow below, as well as discussion of the pros and cons
## Quick Review of BIP-39 Mnemonic Seed Phrases
The typical method for backing up a Bitcoin wallet is to store its [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) mnemonic seed phrase consisting of 12 or 24 words.
-Each word comes from a [list of 2048 words](https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt). The words themselves are meaningless; all that matters is the word's position number (aka index) in the word list.
+**SeedQR specifically assumes and recommends using the English BIP39 wordlist.** While BIP39 supports multiple language-specific wordlists (e.g., Japanese, Spanish, French), there is no mechanism to specify or detect which language wordlist was used when creating or scanning a SeedQR. Using a non-English wordlist will result in an incorrect or invalid seed.
+
+Each word comes from a [list of 2048 words](https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt). The words are sorted alphabetically. This allows us to refer to a specific word by its position number (aka index) in the word list.
For example, "tomato" is the 1,825th word in the list.
-But code always starts counting list items with zero. So the index of "tomato" is actually `1824` (if you're looking at the github wordlist line numbers, just remember to always subtract one).
+But code always starts counting list items from zero. So the index of "tomato" is actually `1824`. The GitHub wordlist and most other resources number the words in a human-friendly manner that starts counting from `1`. But for SeedQR you'll have to remember to subtract one from any such list in order to get the correct index number that SeedQR is expecting.
-So we can transcode a 12-word seed into a series of indices:
+So we can convert a 12-word seed into a series of indices:
```
1. vacuum 1924
@@ -46,7 +48,7 @@ So we can transcode a 12-word seed into a series of indices:
```
# "Standard" `SeedQR` Specification
-We take the indices of the mnemonic seed phrase and concatenate them into one long stream of digits. Each index must be exactly four digits so shorter numbers must be zero-padded (`12` becomes `0012`).
+We take the indices of the mnemonic seed phrase and write them in order, one after the next as one long stream of digits. Each index must be exactly four digits, so shorter numbers must be zero-padded (`12` becomes `0012`).
Using the example above:
@@ -264,7 +266,7 @@ But there are other tradeoffs to consider.
## Recoverability
If you lose your SeedSigner or somehow the project is abandoned or banned, how will you read back your SeedQR?
-With the Standard SeedQR format this is trivial--any smartphone can decode the numeric digit stream. But the CompactSeedQR's raw byte data is not decipherable in the same way. Most QR readers today assume the data is either alphanumeric or human-readable numeric data. Because of this assumption, they misinterpret the binary format data:
+With the Standard SeedQR format, this is trivial--any smartphone can decode the numeric digit stream. But the CompactSeedQR's raw byte data is not decipherable in the same way. Most QR readers today assume the data is either alphanumeric or human-readable numeric data. Because of this assumption, they misinterpret the binary format data:
@@ -283,7 +285,7 @@ It's just the above process in reverse:
# Separate out into 4-digit individual indices
1924 0222 0235 1743 0631 1124 0378 1770 0641 1980 1290 1210
-# Look up each BIP-39 index number (index+1 if using the github list!)
+# Look up each BIP-39 index number (index+1 if using the GitHub list!)
1. vacuum 1924
2. bridge 222
3. buddy 235
@@ -298,7 +300,7 @@ It's just the above process in reverse:
12. nuclear 1210
```
-It would be much more difficult to manually recreate your seed from a CompactSeedQR. Tools like [zxing.org](https://zxing.org/w/decode.jspx) or [ZBar](https://zbar.sourceforge.net/) can help you get the binary data out as a hexidecimal string:
+It would be much more difficult to manually recreate your seed from a CompactSeedQR. Tools like [zxing.org](https://zxing.org/w/decode.jspx) or [ZBar](https://zbar.sourceforge.net/) can help you get the binary data out as a hexadecimal string:
@@ -313,7 +315,7 @@ Conversely, having limited support for reading binary QR codes and the complicat
# Some Additional Notes on QR Codes
Our main use case is to be able to quickly initialize a SeedSigner with your mnemonic seed phrase. But using a QR code as your key loader--or even as your permanent backup etched in metal--has other advantages.
-QR codes are ubiquitous now so plenty of hardware and software exists to read and generate them.
+QR codes are ubiquitous now, so plenty of hardware and software exists to read and generate them.
QR codes have built-in error correction. The "L" error correction mode is described as having a roughly 7% correction rate.
diff --git a/docs/usb_relay.md b/docs/usb_relay.md
index 243aaab9..7bbeca87 100644
--- a/docs/usb_relay.md
+++ b/docs/usb_relay.md
@@ -1,6 +1,6 @@
## Relaying internet access to the Pi Zero 1.3 over USB
-Note that this is an optional, alternate way to initialize your SeedSigner. The default method is to work on a separate Raspberry Pi device that has internet access. Be aware that by enabling internet access over USB you are obviously creating a link to the outside world that this project seeks to avoid.
+Note that this is an optional, alternate way to initialize your SeedSigner. The default method is to work on a separate Raspberry Pi device that has internet access. Be aware that by enabling internet access over USB, you are creating a link to the outside world that this project seeks to avoid.
If you use this setup route, we recommend that you disable internet access over USB when these steps are complete.
@@ -8,17 +8,17 @@ If you use this setup route, we recommend that you disable internet access over
### Get started
Insert the SD card with your Raspberry Pi OS image into a regular computer. Open a terminal window (macOS: Terminal; Windows: Command Prompt) and navigate to the SD card:
-```
-# mac/Linux:
+```bash
+# macOS/Linux:
cd /Volumes/boot
# Windows (alter with correct drive letter):
cd e:
```
-We need to create an empty file called "ssh" to enable us to remotely terminal into the Pi via SSH.
-```
-# mac/Linux:
+We need to create an empty file called `ssh` to enable remote terminal access to the Pi via SSH:
+```bash
+# macOS/Linux:
touch ssh
# Windows:
@@ -29,8 +29,8 @@ type nul > ssh
Now we have some incomprehensible configuration steps to set up the internet access relay.
Edit `config.txt`:
-```
-# mac/Linux:
+```bash
+# macOS/Linux:
nano config.txt
# Windows:
@@ -39,17 +39,15 @@ notepad config.txt
Add `dtoverlay=dwc2` to the end of `config.txt`. Exit and save changes (CTRL-X, then "y" in nano).
-
Alternatively, add to `config.txt` via the command line:
-```
-# mac/Linux/Windows:
+```bash
+# macOS/Linux/Windows:
echo dtoverlay=dwc2 >> config.txt
```
-
Next, edit `cmdline.txt`:
-```
-# mac/Linux:
+```bash
+# macOS/Linux:
nano cmdline.txt
# Windows:
@@ -68,7 +66,7 @@ If you are on Linux, you need to first [set a static IP](#set-a-static-ip-on-lin
Eject the SD card and insert it into your Pi Zero 1.3. Plug a USB cable into your computer and into the Pi's USB connector that is closer to the center. It will draw power from the USB cable and begin powering up.
The Pi will take a minute or so to boot up its OS. After waiting a bit, try to communicate with the Pi over SSH:
-```
+```bash
# Manual builds:
ssh pi@raspberrypi.local
@@ -83,7 +81,7 @@ ECDSA key fingerprint is SHA256:go4yVgii1GcvyxzhOe03atLn5bl2NhZlOR04tJHBo+k.
Are you sure you want to continue connecting (yes/no/[fingerprint])?
```
-At the password prompt enter the Pi's default password: `raspberry`
+At the password prompt, enter the Pi's default password: `raspberry`
If you now see the Pi's command prompt, you're in!
@@ -96,7 +94,7 @@ This is a security risk - please login as the 'pi' user and type 'passwd' to set
```
If someone savvy got access to your SeedSigner, they could sign into it and potentially upload malicious code. To add an extra layer of protection, change the default 'pi' user's password now by typing `passwd`:
-```
+```bash
pi@raspberrypi:~ $ passwd
Changing password for pi.
Current password:
@@ -105,7 +103,6 @@ Retype new password:
passwd: password updated successfully
```
-
## Configure host computer to share internet access with the Pi
### macOS
@@ -126,7 +123,7 @@ Now go to the "Sharing" system settings. Click on "Internet Sharing" and check t
Click the "Internet Sharing" checkbox to activate.
Back at your SSH terminal, test the connection:
-```
+```bash
ping 8.8.8.8
```
@@ -199,11 +196,12 @@ What you will need:
_Note: Remember to remove the brackets. For example, `ip_address=` should be written as: `ip_address=192.168.21.21`_
#### On the SD card
-```
+```bash
cd rootfs/etc
nano dhcpcd.conf
```
-At the end of the file, add the following lines.
+
+At the end of the file, add the following lines:
```
interface usb0
static ip_address=/24
@@ -220,7 +218,7 @@ Type `lsusb` to see if your Pi Zero is properly connected. Look for `Ethernet/RN

Set a name for your new Interface:
-```
+```bash
nano /etc/systemd/network/10-rename-rpi0.link
```
@@ -232,8 +230,8 @@ Property=ID_VENDOR_ID=0525 "ID_MODEL_ID=a4a2"
Name=seedsigner0
```
-To give your Host a static ip:
-```
+To give your Host a static IP:
+```bash
nano /etc/network/interfaces.d/seedsigner
```
@@ -263,8 +261,8 @@ Return to the main [README](../README.md) and complete the setup steps. But reme
Power down the SeedSigner and remove the SD card. Put the SD card back into your computer and use a terminal to navigate to the `boot` drive (same process as above).
Edit `config.txt`:
-```
-# mac/Linux:
+```bash
+# macOS/Linux:
nano config.txt
# Windows:
@@ -275,8 +273,8 @@ Delete the `dtoverlay=dwc2` line that we added to the end of the file. Exit and
Edit `cmdline.txt`:
-```
-# mac/Linux:
+```bash
+# macOS/Linux:
nano cmdline.txt
# Windows:
@@ -290,12 +288,12 @@ Eject the SD card and put it back in the SeedSigner. Connect the SeedSigner to y
SSH into the pi and try to `ping 8.8.8.8`. It should fail with no responses.
For full security, put the SD card back in your computer one last time and delete the `ssh` file from `boot`:
-```
-# mac/Linux:
+```bash
+# macOS/Linux:
rm ssh
# Windows:
del ssh
```
-You're now good to go with a SeedSigner that is back to being fully air-gapped!
\ No newline at end of file
+You're now good to go with a SeedSigner that is back to being fully air-gapped!
diff --git a/l10n/messages.pot b/l10n/messages.pot
index 666c12cc..13ceef0b 100644
--- a/l10n/messages.pot
+++ b/l10n/messages.pot
@@ -1,14 +1,14 @@
# Translations template for seedsigner.
-# Copyright (C) 2025 ORGANIZATION
+# Copyright (C) 2026 ORGANIZATION
# This file is distributed under the same license as the seedsigner project.
-# FIRST AUTHOR , 2025.
+# FIRST AUTHOR , 2026.
#
#, fuzzy
msgid ""
msgstr ""
-"Project-Id-Version: seedsigner 0.8.5-rc1\n"
+"Project-Id-Version: seedsigner 0.8.7\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2025-01-28 13:28-0600\n"
+"POT-Creation-Date: 2026-03-05 22:02+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -51,11 +51,11 @@ msgid "SD card inserted"
msgstr ""
#: src/seedsigner/gui/screens/psbt_screens.py
-msgid "Review PSBT"
+msgid "Review Transaction"
msgstr ""
#: src/seedsigner/gui/screens/psbt_screens.py
-msgid "Review Details"
+msgid "Review details"
msgstr ""
#: src/seedsigner/gui/screens/psbt_screens.py
@@ -117,11 +117,11 @@ msgid "change"
msgstr ""
#: src/seedsigner/gui/screens/psbt_screens.py
-msgid "PSBT Math"
+msgid "Transaction Math"
msgstr ""
#: src/seedsigner/gui/screens/psbt_screens.py
-msgid "Review Recipients"
+msgid "Review recipients"
msgstr ""
#: src/seedsigner/gui/screens/psbt_screens.py
@@ -141,19 +141,21 @@ msgstr[1] ""
msgid "{} change"
msgstr ""
+#. Describes the address type (change or receive)
#: src/seedsigner/gui/screens/psbt_screens.py
-#: src/seedsigner/models/settings_definition.py
-#: src/seedsigner/views/seed_views.py
-msgid "Multisig"
+#: src/seedsigner/gui/screens/seed_screens.py
+msgid "change address"
msgstr ""
+#. Describes the address type (change or receive)
#: src/seedsigner/gui/screens/psbt_screens.py
-msgid "Change"
+#: src/seedsigner/gui/screens/seed_screens.py
+msgid "receive address"
msgstr ""
-#. Abbreviation for receive address
+#. Symbol for index number, e.g. "address #3"
#: src/seedsigner/gui/screens/psbt_screens.py
-msgid "Addr"
+msgid "#"
msgstr ""
#: src/seedsigner/gui/screens/psbt_screens.py
@@ -166,7 +168,7 @@ msgid "raw hex data"
msgstr ""
#: src/seedsigner/gui/screens/psbt_screens.py
-msgid "Sign PSBT"
+msgid "Sign Transaction"
msgstr ""
#: src/seedsigner/gui/screens/psbt_screens.py
@@ -214,7 +216,7 @@ msgid "Privacy Leak!"
msgstr ""
#: src/seedsigner/gui/screens/screen.py
-msgid "I Understand"
+msgid "I understand"
msgstr ""
#: src/seedsigner/gui/screens/screen.py
@@ -237,14 +239,6 @@ msgid ""
"All in-memory data will be wiped."
msgstr ""
-#: src/seedsigner/gui/screens/screen.py
-msgid "Powering Off"
-msgstr ""
-
-#: src/seedsigner/gui/screens/screen.py
-msgid "Please wait about 30 seconds before disconnecting power."
-msgstr ""
-
#: src/seedsigner/gui/screens/screen.py
msgid "Just Unplug It"
msgstr ""
@@ -264,17 +258,6 @@ msgstr ""
msgid "fingerprint"
msgstr ""
-#: src/seedsigner/gui/screens/seed_screens.py
-#: src/seedsigner/views/seed_views.py
-msgid "Backup Seed"
-msgstr ""
-
-#. Additional explainer for the two seed backup options (mnemonic phrase and
-#. SeedQR).
-#: src/seedsigner/gui/screens/seed_screens.py
-msgid "Backups do not include your passphrase."
-msgstr ""
-
#. Displays the page number and total: (e.g. page 1 of 6)
#: src/seedsigner/gui/screens/seed_screens.py
msgid "Seed Words: {}/{}"
@@ -298,14 +281,14 @@ msgstr ""
#: src/seedsigner/gui/screens/seed_screens.py
#: src/seedsigner/views/seed_views.py
-msgid "Export Xpub"
+msgid "Export xpub"
msgstr ""
#: src/seedsigner/gui/screens/seed_screens.py
msgid "Xpub Details"
msgstr ""
-#. Short for "BIP32 Master Fingerprint"
+#. Short for "BIP-32 Master Fingerprint"
#. a label for the shortened Key-id of a BIP-32 master HD wallet
#: src/seedsigner/gui/screens/seed_screens.py
#: src/seedsigner/gui/screens/tools_screens.py
@@ -319,7 +302,6 @@ msgstr ""
msgid "Derivation"
msgstr ""
-#. Short for "BIP32 Extended Public Key"
#: src/seedsigner/gui/screens/seed_screens.py
msgid "Xpub"
msgstr ""
@@ -380,7 +362,7 @@ msgid ""
msgstr ""
#: src/seedsigner/gui/screens/seed_screens.py
-#: src/seedsigner/views/seed_views.py src/seedsigner/views/tools_views.py
+#: src/seedsigner/views/seed_views.py
msgid "Verify Address"
msgstr ""
@@ -393,16 +375,6 @@ msgstr ""
msgid "Address Verified"
msgstr ""
-#. Describes the address type (change or receive)
-#: src/seedsigner/gui/screens/seed_screens.py
-msgid "change address"
-msgstr ""
-
-#. Describes the address type (change or receive)
-#: src/seedsigner/gui/screens/seed_screens.py
-msgid "receive address"
-msgstr ""
-
#. Describes the address index (e.g. "index 7")
#: src/seedsigner/gui/screens/seed_screens.py
msgid "index {}"
@@ -448,7 +420,7 @@ msgstr ""
#: src/seedsigner/gui/screens/seed_screens.py
#: src/seedsigner/views/seed_views.py
-msgid "Sign Message"
+msgid "Sign message"
msgstr ""
#: src/seedsigner/gui/screens/seed_screens.py
@@ -593,6 +565,11 @@ msgstr ""
msgid "Wallet descriptor"
msgstr ""
+#. Insert the number of addrs displayed per screen (e.g. "Next 10")
+#: src/seedsigner/gui/screens/tools_screens.py
+msgid "Next {}"
+msgstr ""
+
#: src/seedsigner/models/settings_definition.py
msgid "Enabled"
msgstr ""
@@ -609,6 +586,21 @@ msgstr ""
msgid "Required"
msgstr ""
+#. QR code format option; "default" = this is the format most wallets use
+#: src/seedsigner/models/settings_definition.py
+msgid "Animated (default)"
+msgstr ""
+
+#. QR code format option (static = single frame, not animated)
+#: src/seedsigner/models/settings_definition.py
+msgid "Static"
+msgstr ""
+
+#. QR code format option: old format that Specter Desktop used to use
+#: src/seedsigner/models/settings_definition.py
+msgid "Specter legacy"
+msgstr ""
+
#: src/seedsigner/models/settings_definition.py
msgid "BTC"
msgstr ""
@@ -677,6 +669,11 @@ msgstr ""
msgid "Single Sig"
msgstr ""
+#: src/seedsigner/models/settings_definition.py
+#: src/seedsigner/views/seed_views.py
+msgid "Multisig"
+msgstr ""
+
#: src/seedsigner/models/settings_definition.py
msgid "Native Segwit"
msgstr ""
@@ -697,7 +694,18 @@ msgstr ""
msgid "Custom Derivation"
msgstr ""
-#. Terminology used by Electrum seeds; equivalent to bip39 passphrase
+#. MicroSD notification duration setting - Display notification for 5 seconds
+#: src/seedsigner/models/settings_definition.py
+msgid "5 seconds"
+msgstr ""
+
+#. MicroSD notification duration setting - Display notification until the SD
+#. card is removed
+#: src/seedsigner/models/settings_definition.py
+msgid "Until SD removed"
+msgstr ""
+
+#. Terminology used by Electrum seeds; equivalent to BIP-39 passphrase
#: src/seedsigner/models/settings_definition.py
msgid "Custom Extension"
msgstr ""
@@ -714,10 +722,6 @@ msgstr ""
msgid "Persistent settings"
msgstr ""
-#: src/seedsigner/models/settings_definition.py
-msgid "Coordinator software"
-msgstr ""
-
#: src/seedsigner/models/settings_definition.py
msgid "Denomination display"
msgstr ""
@@ -730,10 +734,6 @@ msgstr ""
msgid "QR code density"
msgstr ""
-#: src/seedsigner/models/settings_definition.py
-msgid "Xpub export"
-msgstr ""
-
#: src/seedsigner/models/settings_definition.py
msgid "Sig types"
msgstr ""
@@ -742,6 +742,10 @@ msgstr ""
msgid "Script types"
msgstr ""
+#: src/seedsigner/models/settings_definition.py
+msgid "Xpub QR format"
+msgstr ""
+
#: src/seedsigner/models/settings_definition.py
msgid "Show xpub details"
msgstr ""
@@ -770,6 +774,10 @@ msgstr ""
msgid "Native Segwit only"
msgstr ""
+#: src/seedsigner/models/settings_definition.py
+msgid "MicroSD notification duration"
+msgstr ""
+
#: src/seedsigner/models/settings_definition.py
msgid "Message signing"
msgstr ""
@@ -790,6 +798,17 @@ msgstr ""
msgid "Show partner logos"
msgstr ""
+#. Hardware settings option to specify the screen driver (e.g. st7789 vs
+#. ili9341)
+#: src/seedsigner/models/settings_definition.py
+msgid "Display type"
+msgstr ""
+
+#. Hardware settings option to invert how the screen driver displays colors.
+#: src/seedsigner/models/settings_definition.py
+msgid "Invert colors"
+msgstr ""
+
#: src/seedsigner/models/settings_definition.py
msgid "QR background color"
msgstr ""
@@ -834,11 +853,11 @@ msgstr ""
#: src/seedsigner/views/psbt_views.py
msgid ""
-"PSBT has unsupported input script type, please verify your change "
+"Transaction has unsupported input script type, please verify your change "
"addresses."
msgstr ""
-#: src/seedsigner/views/psbt_views.py
+#: src/seedsigner/views/psbt_views.py src/seedsigner/views/view.py
msgid "Continue"
msgstr ""
@@ -850,8 +869,8 @@ msgstr ""
#: src/seedsigner/views/psbt_views.py
msgid ""
-"This PSBT spends its entire input value. No change is coming back to your"
-" wallet."
+"This transaction spends its entire input value. No change is coming back "
+"to your wallet."
msgstr ""
#. Future-tense used to indicate that this transaction will send this amount,
@@ -862,15 +881,15 @@ msgid "Will Send"
msgstr ""
#: src/seedsigner/views/psbt_views.py
-msgid "Next Recipient"
+msgid "Next recipient"
msgstr ""
#: src/seedsigner/views/psbt_views.py
-msgid "Skip Verification"
+msgid "Skip verification"
msgstr ""
#: src/seedsigner/views/psbt_views.py
-msgid "Verify Multisig Change"
+msgid "Verify multisig change"
msgstr ""
#. The amount you're receiving back from the transaction
@@ -883,7 +902,7 @@ msgid "Self-Transfer"
msgstr ""
#: src/seedsigner/views/psbt_views.py
-msgid "Verify Multisig Addr"
+msgid "Verify multisig addr"
msgstr ""
#: src/seedsigner/views/psbt_views.py
@@ -896,16 +915,16 @@ msgstr ""
#. Variable is either "change" or "self-transfer".
#: src/seedsigner/views/psbt_views.py
-msgid "PSBT's {} address could not be verified from wallet descriptor."
+msgid "Transaction's {} address could not be verified from wallet descriptor."
msgstr ""
#. Variable is either "change" or "self-transfer".
#: src/seedsigner/views/psbt_views.py
-msgid "PSBT's {} address could not be generated from your seed."
+msgid "Transaction's {} address could not be generated from your seed."
msgstr ""
#: src/seedsigner/views/psbt_views.py
-msgid "Suspicious PSBT"
+msgid "Suspicious Transaction"
msgstr ""
#: src/seedsigner/views/psbt_views.py
@@ -913,19 +932,19 @@ msgid "Address Verification Failed"
msgstr ""
#: src/seedsigner/views/psbt_views.py
-msgid "Discard PSBT"
+msgid "Discard transaction"
msgstr ""
#: src/seedsigner/views/psbt_views.py
-msgid "Approve PSBT"
+msgid "Approve transaction"
msgstr ""
#: src/seedsigner/views/psbt_views.py
-msgid "Select Diff Seed"
+msgid "Select different seed"
msgstr ""
#: src/seedsigner/views/psbt_views.py
-msgid "PSBT Error"
+msgid "Transaction Error"
msgstr ""
#: src/seedsigner/views/psbt_views.py
@@ -948,12 +967,12 @@ msgstr ""
msgid "Wrong QR Type"
msgstr ""
-#: src/seedsigner/views/scan_views.py src/seedsigner/views/seed_views.py
-msgid "Scan PSBT"
+#: src/seedsigner/views/scan_views.py
+msgid "Scan Transaction"
msgstr ""
#: src/seedsigner/views/scan_views.py
-msgid "Expected a PSBT"
+msgid "Expected a transaction"
msgstr ""
#: src/seedsigner/views/scan_views.py
@@ -964,7 +983,7 @@ msgstr ""
msgid "Expected a SeedQR"
msgstr ""
-#: src/seedsigner/views/scan_views.py
+#: src/seedsigner/views/scan_views.py src/seedsigner/views/seed_views.py
msgid "Scan descriptor"
msgstr ""
@@ -988,9 +1007,8 @@ msgstr ""
msgid "QRCode is invalid or is a data format not yet supported."
msgstr ""
-#: src/seedsigner/views/scan_views.py src/seedsigner/views/seed_views.py
-#: src/seedsigner/views/view.py
-msgid "Done"
+#: src/seedsigner/views/scan_views.py src/seedsigner/views/view.py
+msgid "Back to Main Menu"
msgstr ""
#. This is on the opening splash screen, displayed above the HRF logo
@@ -1014,6 +1032,10 @@ msgstr ""
msgid "Select seed to verify"
msgstr ""
+#: src/seedsigner/views/seed_views.py
+msgid "Sign Message"
+msgstr ""
+
#: src/seedsigner/views/seed_views.py
msgid "Load the seed to sign with"
msgstr ""
@@ -1031,7 +1053,7 @@ msgid "Create a seed"
msgstr ""
#: src/seedsigner/views/seed_views.py
-msgid "Load A Seed"
+msgid "Load a Seed"
msgstr ""
#. Inserts the word number (e.g. "Seed Word #6")
@@ -1040,7 +1062,7 @@ msgid "Seed Word #{}"
msgstr ""
#: src/seedsigner/views/seed_views.py
-msgid "Review & Edit"
+msgid "Review & edit"
msgstr ""
#: src/seedsigner/views/seed_views.py src/seedsigner/views/tools_views.py
@@ -1055,6 +1077,10 @@ msgstr ""
msgid "Checksum failure; not a valid seed phrase."
msgstr ""
+#: src/seedsigner/views/seed_views.py
+msgid "Done"
+msgstr ""
+
#: src/seedsigner/views/seed_views.py
msgid "Edit passphrase"
msgstr ""
@@ -1063,16 +1089,28 @@ msgstr ""
msgid "Discard passphrase"
msgstr ""
+#: src/seedsigner/views/seed_views.py
+msgid "Skip passphrase"
+msgstr ""
+
#: src/seedsigner/views/seed_views.py
msgid "Discard passphrase?"
msgstr ""
#: src/seedsigner/views/seed_views.py
-msgid "Your current passphrase entry will be erased"
+msgid "Your current passphrase entry will be erased."
msgstr ""
#: src/seedsigner/views/seed_views.py
-msgid "Keep Seed"
+msgid "Skip passphrase?"
+msgstr ""
+
+#: src/seedsigner/views/seed_views.py
+msgid "You have not entered a passphrase yet."
+msgstr ""
+
+#: src/seedsigner/views/seed_views.py
+msgid "Keep seed"
msgstr ""
#. Inserts the seed fingerprint
@@ -1085,7 +1123,7 @@ msgid "Discard Seed?"
msgstr ""
#: src/seedsigner/views/seed_views.py
-msgid "Electrum warning"
+msgid "Electrum Warning"
msgstr ""
#: src/seedsigner/views/seed_views.py
@@ -1093,25 +1131,45 @@ msgid "Some features are disabled for Electrum seeds."
msgstr ""
#: src/seedsigner/views/seed_views.py
-msgid "Verify Addr"
+msgid "Scan transaction"
+msgstr ""
+
+#: src/seedsigner/views/seed_views.py src/seedsigner/views/tools_views.py
+msgid "Address explorer"
msgstr ""
#: src/seedsigner/views/seed_views.py
-msgid "BIP-85 Child Seed"
+msgid "Backup seed"
msgstr ""
#: src/seedsigner/views/seed_views.py
-msgid "Discard Seed"
+msgid "BIP-85 child seed"
msgstr ""
#: src/seedsigner/views/seed_views.py
-msgid "View Seed Words"
+msgid "Discard seed"
+msgstr ""
+
+#: src/seedsigner/views/seed_views.py
+msgid "View seed words"
msgstr ""
#: src/seedsigner/views/seed_views.py
msgid "Export as SeedQR"
msgstr ""
+#: src/seedsigner/views/seed_views.py
+msgid "Backup Seed"
+msgstr ""
+
+#: src/seedsigner/views/seed_views.py
+msgid "Export Xpub"
+msgstr ""
+
+#: src/seedsigner/views/seed_views.py
+msgid "Xpub QR Format"
+msgstr ""
+
#: src/seedsigner/views/seed_views.py
msgid "Xpub can be used to view all future transactions."
msgstr ""
@@ -1158,7 +1216,7 @@ msgid "BIP-85 Child Index must be between 0 and 2^31-1."
msgstr ""
#: src/seedsigner/views/seed_views.py
-msgid "Try Again"
+msgid "Try again"
msgstr ""
#: src/seedsigner/views/seed_views.py
@@ -1175,7 +1233,7 @@ msgid "Verify Word #{}"
msgstr ""
#: src/seedsigner/views/seed_views.py
-msgid "Review Seed Words"
+msgid "Review seed words"
msgstr ""
#. Inserts the word number and the word (e.g. "Word #1 is not "apple"!")
@@ -1254,11 +1312,11 @@ msgid "Review SeedQR"
msgstr ""
#: src/seedsigner/views/seed_views.py
-msgid "Your transcribed SeedQR successfully scanned and yielded the same seed."
+msgid "Your transcribed SeedQR could not be read!"
msgstr ""
#: src/seedsigner/views/seed_views.py
-msgid "Your transcribed SeedQR could not be read!"
+msgid "Your transcribed SeedQR successfully scanned and yielded the same seed."
msgstr ""
#: src/seedsigner/views/seed_views.py
@@ -1275,33 +1333,56 @@ msgid "Cancel"
msgstr ""
#: src/seedsigner/views/seed_views.py
-msgid "Can't validate a single sig addr without specifying a seed"
+msgid "Return to transaction"
msgstr ""
#: src/seedsigner/views/seed_views.py
-msgid "Scan Descriptor"
+msgid "Verify addr"
msgstr ""
+#. Multisig policy. For a "2 of 3" policy, "threshold" = 2; "n" = 3
#: src/seedsigner/views/seed_views.py
-msgid "Return to PSBT"
+msgid "{threshold} of {n}"
msgstr ""
#: src/seedsigner/views/seed_views.py
msgid "Signing messages for custom derivation paths not supported"
msgstr ""
-#: src/seedsigner/views/settings_views.py
-msgid "I/O test"
-msgstr ""
-
#: src/seedsigner/views/settings_views.py
msgid "Advanced"
msgstr ""
+#: src/seedsigner/views/settings_views.py
+msgid "Hardware"
+msgstr ""
+
+#: src/seedsigner/views/settings_views.py
+msgid "I/O test"
+msgstr ""
+
#: src/seedsigner/views/settings_views.py
msgid "Dev Options"
msgstr ""
+#. Title of a warning dialog when configuring a setting that requires at least
+#. one option to be selected.
+#: src/seedsigner/views/settings_views.py
+msgid "Selection Required"
+msgstr ""
+
+#. The name of the setting being configured (e.g. "Script types") will be
+#. inserted.
+#: src/seedsigner/views/settings_views.py
+msgid "At least one option must be selected for \"{}\"."
+msgstr ""
+
+#. Text for the button that returns the user to the setting configuration
+#. screen.
+#: src/seedsigner/views/settings_views.py
+msgid "Return to setting"
+msgstr ""
+
#: src/seedsigner/views/settings_views.py
msgid "Persistent Settings enabled. Settings saved to SD card."
msgstr ""
@@ -1318,6 +1399,10 @@ msgstr ""
msgid "Calc 12th/24th word"
msgstr ""
+#: src/seedsigner/views/tools_views.py
+msgid "Verify address"
+msgstr ""
+
#: src/seedsigner/views/tools_views.py src/seedsigner/views/view.py
msgid "Tools"
msgstr ""
@@ -1331,7 +1416,11 @@ msgid "24 words"
msgstr ""
#: src/seedsigner/views/tools_views.py
-msgid "Mnemonic Length?"
+msgid "Mnemonic Length"
+msgstr ""
+
+#: src/seedsigner/views/tools_views.py
+msgid "Calculating..."
msgstr ""
#. Inserts the number of dice rolls needed for a 12-word mnemonic
@@ -1344,10 +1433,6 @@ msgstr ""
msgid "24 words ({} rolls)"
msgstr ""
-#: src/seedsigner/views/tools_views.py
-msgid "Mnemonic Length"
-msgstr ""
-
#. Label to gather entropy through coin tosses
#: src/seedsigner/views/tools_views.py
msgid "Coin flip entropy"
@@ -1378,12 +1463,17 @@ msgstr ""
#. label for addresses where others send us incoming payments
#: src/seedsigner/views/tools_views.py
-msgid "Receive Addresses"
+msgid "Receive addresses"
msgstr ""
#. label for addresses that collect the change from our own outgoing payments
#: src/seedsigner/views/tools_views.py
-msgid "Change Addresses"
+msgid "Change addresses"
+msgstr ""
+
+#. Multisig policy. For a "2 / 3 multisig" policy, "threshold" = 2; "n" = 3
+#: src/seedsigner/views/tools_views.py
+msgid "{threshold} / {n} multisig"
msgstr ""
#. a status message that our payment addresses are being calculated
@@ -1399,11 +1489,6 @@ msgstr ""
msgid "Single sig descriptors not yet supported"
msgstr ""
-#. Insert the number of addrs displayed per screen (e.g. "Next 10")
-#: src/seedsigner/views/tools_views.py
-msgid "Next {}"
-msgstr ""
-
#: src/seedsigner/views/tools_views.py
msgid "Receive Addrs"
msgstr ""
@@ -1425,7 +1510,7 @@ msgid "Restart"
msgstr ""
#: src/seedsigner/views/view.py
-msgid "Power Off"
+msgid "Power off"
msgstr ""
#: src/seedsigner/views/view.py
@@ -1445,7 +1530,7 @@ msgid "Not Yet Implemented"
msgstr ""
#: src/seedsigner/views/view.py
-msgid "Back to Main Menu"
+msgid "Back to main menu"
msgstr ""
#. The network setting (mainnet/testnet/regtest) doesn't match the provided
@@ -1459,9 +1544,9 @@ msgstr ""
msgid "Change Setting"
msgstr ""
-#. Inserts mainnet/testnet/regtest and derivation path
+#. "network" will be mainnet/testnet/regtest.
#: src/seedsigner/views/view.py
-msgid "Current network setting ({}) doesn't match {}."
+msgid "Current network setting ({network}) doesn't match {derivation_path}."
msgstr ""
#: src/seedsigner/views/view.py
@@ -1469,7 +1554,19 @@ msgid "System Error"
msgstr ""
#: src/seedsigner/views/view.py
-msgid "Update Setting"
+msgid "Hardware Error"
+msgstr ""
+
+#: src/seedsigner/views/view.py
+msgid "Cannot access camera"
+msgstr ""
+
+#: src/seedsigner/views/view.py
+msgid "Disconnect power and check for a loose camera connection."
+msgstr ""
+
+#: src/seedsigner/views/view.py
+msgid "Update setting"
msgstr ""
#. Inserts the name of a settings option (e.g. "Persistent Settings" is
@@ -1482,3 +1579,13 @@ msgstr ""
msgid "Option Disabled"
msgstr ""
+#: src/seedsigner/views/view.py
+msgid "Action Required"
+msgstr ""
+
+#: src/seedsigner/views/view.py
+msgid ""
+"You must remove the\n"
+"MicroSD card to continue."
+msgstr ""
+
diff --git a/l10n/requirements-l10n.txt b/l10n/requirements-l10n.txt
index b83c7a02..d7821516 100644
--- a/l10n/requirements-l10n.txt
+++ b/l10n/requirements-l10n.txt
@@ -1 +1,2 @@
Babel==2.16.0
+setuptools>=82.0.0
diff --git a/pyproject.toml b/pyproject.toml
index 7f34e326..0ca7e0d0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,7 +13,7 @@ description = "Build an offline, airgapped Bitcoin signing device for less than
name = "seedsigner"
readme = "README.md"
requires-python = ">=3.10"
-version = "0.8.5"
+version = "0.8.7"
[project.urls]
"Bug Tracker" = "https://github.com/SeedSigner/seedsigner/issues"
@@ -22,7 +22,7 @@ Repository = "https://github.com/SeedSigner/seedsigner"
[tool.coverage.html]
directory = "coverage_html_report"
-skip_covered = false
+skip_covered = true
skip_empty = true
[tool.coverage.report]
@@ -46,10 +46,16 @@ exclude_lines = [
# Omit; need a different approach to test modules with hardware dependencies
omit = [
"*/__init__.py",
+ "src/seedsigner/hardware/camera.py",
+ "src/seedsigner/hardware/pivideostream.py",
+ "src/seedsigner/hardware/displays/ST7789.py",
+ "src/seedsigner/hardware/displays/st7789_mpy.py",
+ "src/seedsigner/hardware/displays/ili9341.py",
"*/tests/*",
]
-skip_covered = false
+skip_covered = true
skip_empty = true
+precision = 2
[tool.coverage.run]
source = ["src"]
diff --git a/requirements.txt b/requirements.txt
index 89a1a589..7334b127 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
embit==0.8.0
-Pillow==10.0.1
+Pillow==10.3.0
pyzbar @ git+https://github.com/seedsigner/pyzbar.git@c3c237821c6a20b17953efe59b90df0b514a1c03
qrcode==7.3.1
-urtypes @ git+https://github.com/selfcustody/urtypes.git@7fb280eab3b3563dfc57d2733b0bf5cbc0a96a6a
+urtypes==1.0.1
diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py
index 7505408c..be8ef6c9 100644
--- a/src/seedsigner/controller.py
+++ b/src/seedsigner/controller.py
@@ -11,10 +11,11 @@ from seedsigner.models.psbt_parser import PSBTParser
from seedsigner.models.seed import Seed
from seedsigner.models.seed_storage import SeedStorage
from seedsigner.models.settings import Settings
+from seedsigner.models.settings import SettingsConstants
from seedsigner.models.singleton import Singleton
from seedsigner.models.threads import BaseThread
from seedsigner.views.screensaver import ScreensaverScreen
-from seedsigner.views.view import Destination
+from seedsigner.views.view import Destination, View
logger = logging.getLogger(__name__)
@@ -60,7 +61,7 @@ class BackgroundImportThread(BaseThread):
def time_import(module_name):
last = time.time()
import_module(module_name)
- # print(time.time() - last, module_name)
+ # print(f"{time.time() - last:0.4f}: {module_name}")
time_import('embit')
time_import('seedsigner.helpers.embit_utils')
@@ -70,13 +71,13 @@ class BackgroundImportThread(BaseThread):
from seedsigner.models.seed_storage import SeedStorage
Controller.get_instance()._storage = SeedStorage()
+ time_import('numpy') # used by PiVideoStream; by far the slowest import (2.29s)
+ time_import('seedsigner.hardware.pivideostream')
+
# Get MainMenuView ready to respond quickly
time_import('seedsigner.views.scan_views')
-
time_import('seedsigner.views.seed_views')
-
time_import('seedsigner.views.tools_views')
-
time_import('seedsigner.views.settings_views')
@@ -99,7 +100,7 @@ class Controller(Singleton):
rather than at the top in order avoid circular imports.
"""
- VERSION = "0.8.5"
+ VERSION = "0.8.7"
# Declare class member vars with type hints to enable richer IDE support throughout
# the code.
@@ -172,6 +173,11 @@ class Controller(Singleton):
controller = cls.__new__(cls)
cls._instance = controller
+ # Check for libraqm support and log the status if not supported
+ from PIL import features
+ if not features.check('raqm'):
+ logger.warning("libraqm support: NOT AVAILABLE - Complex text rendering may be limited")
+
# models
controller.settings = Settings.get_instance()
@@ -247,7 +253,7 @@ class Controller(Singleton):
* initial_destination: The first View to run. If None, the MainMenuView is
used. Only used by the test suite.
"""
- from seedsigner.views import MainMenuView, BackStackView
+ from seedsigner.views import MainMenuView, BackStackView, RemoveMicroSDWarningView
from seedsigner.views.screensaver import OpeningSplashView
from seedsigner.gui.toast import RemoveSDCardToastManagerThread
@@ -285,7 +291,10 @@ class Controller(Singleton):
next_destination = Destination(MainMenuView)
# Set up our one-time toast notification tip to remove the SD card
- self.activate_toast(RemoveSDCardToastManagerThread())
+ if self.settings.get_value(SettingsConstants.SETTING__MICROSD_TOAST_TIMER) == SettingsConstants.MICROSD_TOAST_TIMER_FIVE_SECONDS:
+ self.activate_toast(RemoveSDCardToastManagerThread())
+ elif self.settings.get_value(SettingsConstants.SETTING__MICROSD_TOAST_TIMER) == SettingsConstants.MICROSD_TOAST_TIMER_FOREVER:
+ next_destination = Destination(RemoveMicroSDWarningView)
while True:
# Destination(None) is a special case; render the Home screen
@@ -466,3 +475,18 @@ class Controller(Singleton):
exception_msg,
]
return Destination(UnhandledExceptionView, view_args={"error": error}, clear_history=True)
+
+
+ @property
+ def is_screensaver_start_allowed(self) -> bool:
+ """
+ Determines whether the screensaver is allowed to start.
+
+ The screensaver can start only if:
+ - It is not currently running.
+ - The current active view allows screensaver activity.
+ """
+ from seedsigner.views import MainMenuView
+ # Confusingly, the top item in the `BackStack` is actually the *current* View
+ active_view = self.back_stack[-1].view if self.back_stack else MainMenuView()
+ return not self.is_screensaver_running and active_view.is_screensaver_allowed
diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py
index 6c0f68ae..e6f7224f 100644
--- a/src/seedsigner/gui/components.py
+++ b/src/seedsigner/gui/components.py
@@ -35,7 +35,7 @@ class GUIConstants:
ERROR_COLOR = "#FF1B0A"
SUCCESS_COLOR = "#30D158"
INFO_COLOR = "#409CFF"
- BITCOIN_ORANGE = "#FF9416"
+ # BITCOIN_ORANGE = "#FF9416" # not used
TESTNET_COLOR = "#00F100"
REGTEST_COLOR = "#00CAF1"
GREEN_INDICATOR_COLOR = "#00FF00"
@@ -48,31 +48,36 @@ class GUIConstants:
ICON_TOAST_FONT_SIZE = 30
ICON_PRIMARY_SCREEN_SIZE = 50
- TOP_NAV_TITLE_FONT_NAME = {
- "default": "OpenSans-SemiBold",
- # "ar": "multilanguage/NotoSansAR-Regular",
- # "he": "multilanguage/NotoSansHE-Regular",
- # "ja": "multilanguage/NotoSansJP-Regular",
- # "kr": "multilanguage/NotoSansKR-Regular",
- # "ru": "multilanguage/NotoSans-Regular",
+ BASE_LOCALE_FONTS = {
+ "default": "OpenSans-Regular",
+ SettingsConstants.LOCALE__ARABIC: "NotoSansAR-Regular",
+ SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: "NotoSansSC-Regular",
+ SettingsConstants.LOCALE__HINDI: "NotoSansDevanagari-Regular",
+ SettingsConstants.LOCALE__JAPANESE: "NotoSansJP-Regular",
+ SettingsConstants.LOCALE__KOREAN: "NotoSansKR-Regular",
+ SettingsConstants.LOCALE__PERSIAN: "NotoSansAR-Regular", # Uses the Arabic font
+ SettingsConstants.LOCALE__THAI: "NotoSansTH-Regular",
}
+
+ TOP_NAV_TITLE_FONT_NAME = BASE_LOCALE_FONTS.copy()
+ TOP_NAV_TITLE_FONT_NAME["default"] = "OpenSans-SemiBold"
TOP_NAV_TITLE_FONT_SIZE = {
"default": 20,
+ SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: 23, # Some chars won't render below 23px
+ SettingsConstants.LOCALE__HINDI: 22,
+ SettingsConstants.LOCALE__JAPANESE: 22, # Titles won't render below 22px
+ SettingsConstants.LOCALE__KOREAN: 23, # Titles won't render below 23px
}
TOP_NAV_HEIGHT = 48
TOP_NAV_BUTTON_SIZE = 32
- BODY_FONT_NAME = {
- "default": "OpenSans-Regular",
- # "ar": "multilanguage/NotoSansAR-Regular",
- # "he": "multilanguage/NotoSansHE-Regular",
- # "ja": "multilanguage/NotoSansJP-Regular",
- # "kr": "multilanguage/NotoSansKR-Regular",
- # "ru": "multilanguage/NotoSans-Regular",
- }
+ BODY_FONT_NAME = BASE_LOCALE_FONTS.copy()
BODY_FONT_SIZE = {
"default": 17,
- # "ar": 16,
+ SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: 18,
+ SettingsConstants.LOCALE__HINDI: 18,
+ SettingsConstants.LOCALE__JAPANESE: 18,
+ SettingsConstants.LOCALE__KOREAN: 18,
}
BODY_FONT_MAX_SIZE = TOP_NAV_TITLE_FONT_SIZE["default"]
BODY_FONT_MIN_SIZE = 15
@@ -82,21 +87,20 @@ class GUIConstants:
FIXED_WIDTH_FONT_NAME = "Inconsolata-Regular"
FIXED_WIDTH_EMPHASIS_FONT_NAME = "Inconsolata-SemiBold"
+ # TODO: this should have a get_label_font_size() method like the others for l10n
LABEL_FONT_SIZE = BODY_FONT_MIN_SIZE
LABEL_FONT_COLOR = "#777777"
- BUTTON_FONT_NAME = {
- "default": "OpenSans-SemiBold",
- # "ar": "multilanguage/NotoSansAR-Regular",
- # "he": "multilanguage/NotoSansHE-Regular",
- # "ja": "multilanguage/NotoSansJP-Regular",
- # "kr": "multilanguage/NotoSansKR-Regular",
- # "ru": "multilanguage/NotoSans-Regular",
- }
+ BUTTON_FONT_NAME = BASE_LOCALE_FONTS.copy()
+ BUTTON_FONT_NAME["default"] = "OpenSans-SemiBold"
BUTTON_FONT_SIZE = {
"default": 18,
- # "ar": 16,
- # "ja": 16,
+ "ar": 18,
+ "fa": 18,
+ SettingsConstants.LOCALE__HINDI: 20,
+ SettingsConstants.LOCALE__JAPANESE: 20,
+ SettingsConstants.LOCALE__KOREAN: 20,
+ SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: 20,
}
BUTTON_FONT_COLOR = "#FCFCFC"
BUTTON_BACKGROUND_COLOR = "#2C2C2C"
@@ -107,8 +111,9 @@ class GUIConstants:
@staticmethod
- def get_body_font_name():
- locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
+ def get_body_font_name(locale=None):
+ if not locale:
+ locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
if locale in GUIConstants.BODY_FONT_NAME:
return GUIConstants.BODY_FONT_NAME[locale]
else:
@@ -116,8 +121,9 @@ class GUIConstants:
@staticmethod
- def get_body_font_size():
- locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
+ def get_body_font_size(locale=None):
+ if not locale:
+ locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
if locale in GUIConstants.BODY_FONT_SIZE:
return GUIConstants.BODY_FONT_SIZE[locale]
else:
@@ -143,8 +149,9 @@ class GUIConstants:
@staticmethod
- def get_button_font_name():
- locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
+ def get_button_font_name(locale=None):
+ if not locale:
+ locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
if locale in GUIConstants.BUTTON_FONT_NAME:
return GUIConstants.BUTTON_FONT_NAME[locale]
else:
@@ -152,8 +159,9 @@ class GUIConstants:
@staticmethod
- def get_button_font_size():
- locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
+ def get_button_font_size(locale=None):
+ if not locale:
+ locale = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE)
if locale in GUIConstants.BUTTON_FONT_SIZE:
return GUIConstants.BUTTON_FONT_SIZE[locale]
else:
@@ -163,14 +171,9 @@ class GUIConstants:
class FontAwesomeIconConstants:
ANGLE_DOWN = "\uf107"
- ANGLE_LEFT = "\uf104"
- ANGLE_RIGHT = "\uf105"
ANGLE_UP = "\uf106"
CAMERA = "\uf030"
- CHEVRON_UP = "\uf077"
- CHEVRON_DOWN = "\uf078"
CIRCLE = "\uf111"
- CIRCLE_CHEVRON_RIGHT = "\uf138"
DICE = "\uf522"
DICE_ONE = "\uf525"
DICE_TWO = "\uf528"
@@ -179,15 +182,7 @@ class FontAwesomeIconConstants:
DICE_FIVE = "\uf523"
DICE_SIX = "\uf526"
KEYBOARD = "\uf11c"
- LOCK = "\uf023"
MAP = "\uf279"
- PAPER_PLANE = "\uf1d8"
- PEN = "\uf304"
- SQUARE_CARET_DOWN = "\uf150"
- SQUARE_CARET_LEFT = "\uf191"
- SQUARE_CARET_RIGHT = "\uf152"
- SQUARE_CARET_UP = "\uf151"
- UNLOCK = "\uf09c"
X = "\u0058"
@@ -208,9 +203,9 @@ class SeedSignerIconConstants:
CHEVRON_LEFT = "\ue909"
CHEVRON_RIGHT = "\ue90a"
CHEVRON_UP = "\ue90b"
- CLOSE = "\ue90c"
- PAGE_DOWN = "\ue90d"
- PAGE_UP = "\ue90e"
+ # CLOSE = "\ue90c" # Unused icons
+ # PAGE_DOWN = "\ue90d"
+ # PAGE_UP = "\ue90e"
PLUS = "\ue90f"
POWER = "\ue910"
RESTART = "\ue911"
@@ -222,17 +217,17 @@ class SeedSignerIconConstants:
ERROR = "\ue915"
# Informational icons
- ADDRESS = "\ue916"
+ # ADDRESS = "\ue916"
CHANGE = "\ue917"
DERIVATION = "\ue918"
- FEE = "\ue919"
+ # FEE = "\ue919"
FINGERPRINT = "\ue91a"
PASSPHRASE = "\ue91b"
# Misc icons
- BITCOIN = "\ue91c"
+ # BITCOIN = "\ue91c"
BITCOIN_ALT = "\ue91d"
- BRIGHTNESS = "\ue91e"
+ # BRIGHTNESS = "\ue91e"
MICROSD = "\ue91f"
QRCODE = "\ue920"
SIGN = "\ue921"
@@ -247,40 +242,6 @@ class SeedSignerIconConstants:
-def calc_text_centering(font: ImageFont,
- text: str,
- is_text_centered: bool,
- total_width: int,
- total_height: int,
- start_x: int = 0,
- start_y: int = 0) -> Tuple[int, int]:
- # see: https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html#text-anchors
-
- # Gap between the starting coordinate and the first marking.
- offset_x, offset_y = font.getoffset(text)
-
- # Bounding box of the actual pixels rendered.
- (box_left, box_top, box_right, box_bottom) = font.getbbox(text, anchor='lt')
-
- # Ascender/descender are oversized ranges baked into the font.
- ascent, descent = font.getmetrics()
-
- # print(f"""----- "{text}" / {font.getname()} -----""")
- # print(f"offset_x: {offset_x} | offset_y: {offset_y})")
- # print(f"box_left: {box_left} | box_top: {box_top} | box_right: {box_right} | box_bottom: {box_bottom}")
- # print(f"ascent: {ascent} | descent: {descent})")
-
- if is_text_centered:
- text_x = int((total_width - (box_right - offset_x)) / 2) - offset_x
- else:
- text_x = GUIConstants.COMPONENT_PADDING
-
- text_y = int((total_height - (ascent - offset_y)) / 2) - offset_y
-
- return (start_x + text_x, start_y + text_y)
-
-
-
def load_image(image_name: str) -> Image.Image:
image_url = os.path.join(pathlib.Path(__file__).parent.resolve(), "..", "resources", "img", image_name)
image = Image.open(image_url).convert("RGB")
@@ -289,11 +250,19 @@ def load_image(image_name: str) -> Image.Image:
class Fonts(Singleton):
- font_path = os.path.join(
- pathlib.Path(__file__).parent.resolve().parent.resolve(),
- "resources",
- "fonts"
- )
+ font_paths = [
+ os.path.join(
+ pathlib.Path(__file__).parent.resolve().parent.resolve(),
+ "resources",
+ "fonts"
+ ),
+ os.path.join(
+ pathlib.Path(__file__).parent.resolve().parent.resolve(),
+ "resources",
+ "seedsigner-translations",
+ "fonts"
+ )
+ ]
fonts = {}
@classmethod
@@ -306,13 +275,24 @@ class Fonts(Singleton):
file_extension = "otf"
if size not in cls.fonts[font_name]:
- try:
- cls.fonts[font_name][size] = ImageFont.truetype(os.path.join(cls.font_path, f"{font_name}.{file_extension}"), size)
- except OSError as e:
- if "cannot open resource" in str(e):
- raise Exception(f"Font {font_name}.{file_extension} not found: {repr(e)}")
+
+ # loop over possible font locations and attempt to load font object
+ # TODO: single location for all font files
+ captured_exception = None
+ for font_path in cls.font_paths:
+ try:
+ cls.fonts[font_name][size] = ImageFont.truetype(os.path.join(font_path, f"{font_name}.{file_extension}"), size)
+ break
+ except OSError as e:
+ captured_exception = e
+ continue
+
+ # throw error at this point if font object was unable to be loaded
+ if size not in cls.fonts[font_name]:
+ if "cannot open resource" in str(captured_exception):
+ raise Exception(f"Font {font_name}.{file_extension} not found: {repr(captured_exception)}")
else:
- raise e
+ raise captured_exception
return cls.fonts[font_name][size]
@@ -386,7 +366,6 @@ class TextArea(BaseComponent):
is_text_centered: bool = True
supersampling_factor: int = 2 # 1 = disabled; 2 = default, double sample (4px square rendered for 1px)
auto_line_break: bool = True
- allow_text_overflow: bool = False
is_horizontal_scrolling_enabled: bool = False
horizontal_scroll_speed: int = 40 # px per sec
horizontal_scroll_begin_hold_secs: float = 2.0
@@ -398,10 +377,6 @@ class TextArea(BaseComponent):
if self.is_horizontal_scrolling_enabled and self.auto_line_break:
raise Exception("TextArea: Cannot have auto_line_break and horizontal scrolling enabled at the same time")
- if self.is_horizontal_scrolling_enabled and not self.allow_text_overflow:
- self.allow_text_overflow = True
- logger.warning("TextArea: allow_text_overflow gets overridden to True when horizontal scrolling is enabled")
-
if not self.font_name:
self.font_name = GUIConstants.get_body_font_name()
if not self.font_size:
@@ -431,16 +406,18 @@ class TextArea(BaseComponent):
self.text_y = self.text_height_above_baseline
self.visible_width = self.width - max(self.edge_padding, self.min_text_x) - self.edge_padding
- if not ImageFont.core.HAVE_RAQM:
- # Fudge factor for imprecise width calcs w/out libraqm
- full_text_width = int(full_text_width * 1.05)
- self.visible_width = int(self.visible_width * 1.05)
if self.is_horizontal_scrolling_enabled or not self.auto_line_break:
# Guaranteed to be a single line of text, possibly wider than self.width
self.text_lines = [{"text": self.text, "text_width": full_text_width}]
self.text_width = full_text_width
- if self.text_width > self.visible_width:
+
+ # Technically, the math says that we should enable scrolling for as little as
+ # 1px beyond the available width, but it's not worth having the text twitch
+ # back and forth by such a small amount.
+ min_scrollable_diff = 1
+
+ if self.text_width > self.visible_width + min_scrollable_diff:
# We'll have to left justify the text and scroll it (if scrolling is enabled,
# otherwise it'll just run off the right edge).
self.is_text_centered = False
@@ -458,7 +435,6 @@ class TextArea(BaseComponent):
width=self.width - 2*self.edge_padding,
font_name=self.font_name,
font_size=self.font_size,
- allow_text_overflow=self.allow_text_overflow,
)
# Other components, like IconTextLine will need to know how wide the actual
@@ -474,9 +450,9 @@ class TextArea(BaseComponent):
# Multiply for the number of lines plus the spacer
total_text_height = self.text_height_above_baseline * len(self.text_lines) + self.line_spacing * (len(self.text_lines) - 1)
- if not self.height_ignores_below_baseline and re.findall(f"[gjpqy]", self.text_lines[-1]["text"]):
- # Last line has at least one char that dips below baseline
- total_text_height += self.text_height_below_baseline
+ if not self.height_ignores_below_baseline:
+ # Account for the last line possibly rendering below baseline
+ total_text_height += self.text_lines[-1].get("px_below_baseline", 0)
self.text_offset_y = 0
if self.height is None:
@@ -485,14 +461,9 @@ class TextArea(BaseComponent):
else:
if total_text_height > self.height:
- if not self.allow_text_overflow:
- # For now, early into the l10n rollout, we can't enforce strict
- # conformance here. Too many screens will just break if this is were
- # to raise an exception.
- logger.warning(f"Text cannot fit in target rect with this font/size\n\ttotal_text_height: {total_text_height} | self.height: {self.height}")
- else:
- # Just let it render past the bottom edge
- pass
+ # Let it render past the bottom edge. Will be up to the dev or translator
+ # to review the screenshot and revise the text as needed.
+ logger.warning(f"Text cannot fit in target rect with this font/size\n\ttotal_text_height: {total_text_height} | self.height: {self.height}")
else:
# Vertically center the text's starting point
@@ -581,17 +552,13 @@ class TextArea(BaseComponent):
# Crop off the top_padding and resize the result down to onscreen size
if self.supersampling_factor > 1:
- resized = img.resize((image_width, total_text_height + 2*resample_padding), Image.LANCZOS)
+ resized = img.resize((image_width, total_text_height + 2*resample_padding), Image.Resampling.LANCZOS)
sharpened = resized.filter(ImageFilter.SHARPEN)
img = sharpened.crop((0, resample_padding, image_width, resample_padding + total_text_height))
self.rendered_text_img = img
- if not ImageFont.core.HAVE_RAQM:
- # At this point we need the visible_width to be the "actual" (yet still incorrect) width
- self.visible_width = int(self.visible_width * 0.95)
-
self.horizontal_text_scroll_thread: TextArea.HorizontalTextScrollThread = None
if self.is_horizontal_scrolling_enabled:
self.horizontal_text_scroll_thread = TextArea.HorizontalTextScrollThread(
@@ -627,7 +594,7 @@ class TextArea(BaseComponent):
self.horizontal_scroll_position = 0
self.scroll_increment_sign = 1 # flip to negative to scroll text to the right
- self.renderer = Renderer.get_instance()
+ self.renderer = Renderer.get_instance()
def stop_scrolling(self):
@@ -648,45 +615,57 @@ class TextArea(BaseComponent):
readability. 45px/sec is better but still perceptually a bit stuttery.
"""
max_scroll = self.rendered_text_img.width - self.visible_width
+ last_render_time = None
+
+ # The scrolling pauses at the start and end of the text line. These vars track
+ # when we started holding and how long we should hold for.
+ hold_started_at = None
+ cur_hold_duration = None
while self.keep_running:
if not self.scrolling_active:
time.sleep(0.1)
continue
- with self.renderer.lock:
- if not self.scrolling_active:
- # We were stopped while waiting for the lock
+ if cur_hold_duration is not None:
+ # We're currently holding; see if we've held long enough
+ hold_time_elapsed = time.time() - hold_started_at
+ if hold_time_elapsed < cur_hold_duration:
+ # Still have to hold longer; skip scrolling logic
+ time.sleep(0.1)
+ continue
+ else:
+ # We've held long enough; reset the vars and resume scrolling
+ hold_started_at = None
+ cur_hold_duration = None
+
+ else:
+ # We're not holding, but if we've reached either end, we need to start
+ # holding.
+ if self.horizontal_scroll_position == 0:
+ # Pause on initial (left-justified) position...
+ hold_started_at = time.time()
+ cur_hold_duration = self.begin_hold_secs
+
+ # Next scroll direction will be left
+ self.scroll_increment_sign = 1
+
+ # Don't count those pause seconds
+ last_render_time = None
continue
- img = self.rendered_text_img.crop((self.horizontal_scroll_position, 0, self.horizontal_scroll_position + self.visible_width, self.rendered_text_img.height))
- self.renderer.canvas.paste(img, (self.screen_x, self.screen_y - self.scroll_y))
- self.renderer.show_image()
+ elif self.horizontal_scroll_position == max_scroll:
+ # ...and slight pause at end of scroll
+ hold_started_at = time.time()
+ cur_hold_duration = self.end_hold_secs
- if self.horizontal_scroll_position == 0:
- # Pause on initial (left-justified) position...
- time.sleep(self.begin_hold_secs)
+ # Don't count those pause seconds
+ last_render_time = None
- # Don't count those pause seconds
- last_render_time = None
+ # Scroll will be to the right
+ self.scroll_increment_sign = -1
+ continue
- # Scroll the text left
- self.scroll_increment_sign = 1
-
- elif self.horizontal_scroll_position == max_scroll:
- # ...and slight pause at end of scroll
- time.sleep(self.end_hold_secs)
-
- # Don't count those pause seconds
- last_render_time = None
-
- # Scroll the text right
- self.scroll_increment_sign = -1
-
- else:
- # No need to CPU limit when running in its own thread?
- time.sleep(0.02)
-
next_render_time = time.time()
if not last_render_time:
@@ -694,18 +673,39 @@ class TextArea(BaseComponent):
# "get off zero" for the real increment calc logic to kick in.
scroll_position_increment = 1 * self.scroll_increment_sign
else:
+ # Calculate how far to scroll based on time elapsed since last render
scroll_position_increment = int(self.horizontal_scroll_speed * (next_render_time - last_render_time) * self.scroll_increment_sign)
+ # Only render an update if we're going to move at least 1px
if abs(scroll_position_increment) > 0:
- self.horizontal_scroll_position += scroll_position_increment
- self.horizontal_scroll_position = max(0, min(self.horizontal_scroll_position, max_scroll))
+ # max: Don't over-scroll when returning to the left edge (0)
+ # min: Don't over-scroll when revealing the right edge (max_scroll)
+ self.horizontal_scroll_position = max(
+ 0,
+ min(self.horizontal_scroll_position + scroll_position_increment, max_scroll)
+ )
+
+ # Render the scroll update
+ with self.renderer.lock:
+ if not self.scrolling_active:
+ # We were stopped while waiting for the lock
+ continue
+
+ # The pre-rendered text img slides within a cropping window
+ img = self.rendered_text_img.crop((self.horizontal_scroll_position, 0, self.horizontal_scroll_position + self.visible_width, self.rendered_text_img.height))
+ self.renderer.canvas.paste(img, (self.screen_x, self.screen_y - self.scroll_y))
+ self.renderer.show_image()
last_render_time = next_render_time
+
else:
- # Wait to accumulate more time before scrolling
+ # Wait to accumulate more time so we can scroll at least 1px
pass
+ # Free up the processor for a bit each loop
+ time.sleep(0.02)
+
def render(self):
"""
Even if we need to animate for scrolling, all instances should explicitly render
@@ -745,7 +745,6 @@ class ScrollableTextLine(TextArea):
def __post_init__(self):
self.auto_line_break = False
self.is_horizontal_scrolling_enabled = True
- self.allow_text_overflow = True
super().__post_init__()
@@ -807,7 +806,6 @@ class IconTextLine(BaseComponent):
font_size: int = None
is_text_centered: bool = False
auto_line_break: bool = False
- allow_text_overflow: bool = True
screen_x: int = 0
screen_y: int = 0
@@ -850,7 +848,6 @@ class IconTextLine(BaseComponent):
auto_line_break=False,
screen_x=text_screen_x,
screen_y=self.screen_y,
- allow_text_overflow=False,
)
else:
self.label_textarea = None
@@ -870,7 +867,6 @@ class IconTextLine(BaseComponent):
edge_padding=0,
is_text_centered=self.is_text_centered if not self.icon_name else False,
auto_line_break=self.auto_line_break,
- allow_text_overflow=self.allow_text_overflow,
screen_x=text_screen_x,
screen_y=value_textarea_screen_y,
)
@@ -1102,7 +1098,6 @@ class BtcAmount(BaseComponent):
def __post_init__(self):
super().__post_init__()
- self.sub_components: List[BaseComponent] = []
self.paste_image: Image.Image = None
self.paste_coords = None
denomination = Settings.get_instance().get_value(SettingsConstants.SETTING__BTC_DENOMINATION)
@@ -1359,8 +1354,12 @@ class Button(BaseComponent):
text_y_offset: int = 0
background_color: str = GUIConstants.BUTTON_BACKGROUND_COLOR
selected_color: str = GUIConstants.ACCENT_COLOR
+
+ # Cannot define these class attrs w/the get_*_font_*() methods because the attrs will
+ # not be dynamically reinterpreted after initial class import.
font_name: str = None
font_size: int = None
+
font_color: str = GUIConstants.BUTTON_FONT_COLOR
selected_font_color: str = GUIConstants.BUTTON_SELECTED_FONT_COLOR
outline_color: str = None
@@ -1516,7 +1515,6 @@ class Button(BaseComponent):
button_kwargs["text"] = self.text
button_kwargs["font_color"] = self.font_color
button_kwargs["background_color"] = self.background_color
- button_kwargs["allow_text_overflow"] = True
button_kwargs["auto_line_break"] = False
del button_kwargs["horizontal_scroll_begin_hold_secs"]
del button_kwargs["horizontal_scroll_end_hold_secs"]
@@ -1673,8 +1671,12 @@ class TopNav(BaseComponent):
background_color: str = GUIConstants.BACKGROUND_COLOR
icon_name: str = None
icon_color: str = GUIConstants.BODY_FONT_COLOR
- font_name: str = GUIConstants.get_top_nav_title_font_name()
- font_size: int = GUIConstants.get_top_nav_title_font_size()
+
+ # Cannot define these class attrs w/the get_*_font_*() methods because the attrs will
+ # not be dynamically reinterpreted after initial class import.
+ font_name: str = None
+ font_size: int = None
+
font_color: str = GUIConstants.BODY_FONT_COLOR
show_back_button: bool = True
show_power_button: bool = False
@@ -1687,8 +1689,7 @@ class TopNav(BaseComponent):
if not self.font_size:
self.font_size = GUIConstants.get_top_nav_title_font_size()
- print(f"self.font_size: {self.font_size}")
-
+
super().__post_init__()
if not self.width:
self.width = self.canvas_width
@@ -1818,8 +1819,7 @@ def calc_bezier_curve(p1: Tuple[int,int], p2: Tuple[int,int], p3: Tuple[int,int]
def reflow_text_for_width(text: str,
width: int,
font_name=GUIConstants.get_body_font_name(),
- font_size=GUIConstants.get_body_font_size(),
- allow_text_overflow: bool=False) -> list[dict]:
+ font_size=GUIConstants.get_body_font_size()) -> list[dict]:
"""
Reflows text to fit within `width` by breaking long lines up.
@@ -1833,24 +1833,28 @@ def reflow_text_for_width(text: str,
# font.
font = Fonts.get_font(font_name=font_name, size=font_size)
# Measure from left baseline ("ls")
- (left, top, full_text_width, bottom) = font.getbbox(text, anchor="ls")
+ (left, top, full_text_width, px_below_baseline) = font.getbbox(text, anchor="ls")
- if not ImageFont.core.HAVE_RAQM:
- # Fudge factor for imprecise width calcs w/out libraqm
- full_text_width = int(full_text_width * 1.05)
+ # Assume we can break Asian text on any character
+ treat_chars_as_words = Settings.get_instance().get_value(SettingsConstants.SETTING__LOCALE) in [
+ SettingsConstants.LOCALE__CHINESE_SIMPLIFIED,
+ SettingsConstants.LOCALE__CHINESE_TRADITIONAL,
+ SettingsConstants.LOCALE__JAPANESE,
+ SettingsConstants.LOCALE__KOREAN,
+ ]
# Stores each line of text and its rendering starting x-coord
text_lines = []
- def _add_text_line(text, text_width):
- text_lines.append({"text": text, "text_width": text_width})
+ def _add_text_line(text, text_width, px_below_baseline):
+ text_lines.append(dict(text=text, text_width=text_width, px_below_baseline=px_below_baseline))
if "\n" not in text and full_text_width < width:
# The whole text fits on one line
- _add_text_line(text, full_text_width)
+ _add_text_line(text, full_text_width, px_below_baseline)
else:
# Have to calc how to break text into multiple lines
- def _binary_len_search(min_index, max_index):
+ def _binary_len_search(min_index, max_index, word_spacer):
# Try the middle of the range
index = math.ceil((max_index + min_index) / 2)
if index == 0:
@@ -1858,13 +1862,9 @@ def reflow_text_for_width(text: str,
index = 1
# Measure rendered width from "left" anchor (anchor="l_")
- (left, top, right, bottom) = font.getbbox(" ".join(words[0:index]), anchor="ls")
+ (left, top, right, px_below_baseline) = font.getbbox(word_spacer.join(words[0:index]), anchor="ls")
line_width = right - left
- if not ImageFont.core.HAVE_RAQM:
- # Fudge factor for imprecise width calcs w/out libraqm
- line_width = int(line_width * 1.05)
-
if line_width >= width:
# Candidate line is still too long. Restrict search range down.
if min_index + 1 == index:
@@ -1872,33 +1872,50 @@ def reflow_text_for_width(text: str,
# It's just one long, unbreakable word. There's no good
# solution here. Just accept it as is and let it render off
# the edges.
- return (index, line_width)
+ return (index, line_width, px_below_baseline)
else:
# There's still room to back down the min_index in the next
# round.
index -= 1
- return _binary_len_search(min_index=min_index, max_index=index)
+ return _binary_len_search(min_index=min_index, max_index=index, word_spacer=word_spacer)
elif index == max_index:
# We have converged
- return (index, line_width)
+ return (index, line_width, px_below_baseline)
else:
# Candidate line is possibly shorter than necessary.
- return _binary_len_search(min_index=index, max_index=max_index)
+ return _binary_len_search(min_index=index, max_index=max_index, word_spacer=word_spacer)
- if len(text.split()) == 1 and not allow_text_overflow:
- # No whitespace chars to split on!
- raise TextDoesNotFitException("Text cannot fit in target rect with this font+size")
+ if len(text.split()) == 1 and not treat_chars_as_words:
+ # No whitespace chars to split on! Warn but proceed anyway.
+ logger.warning("Text cannot fit in target rect with this font+size")
# Now we're ready to go line-by-line into our line break binary search!
for line in text.split("\n"):
- words = line.split()
+ if treat_chars_as_words:
+ # Each char in `line` will be considered a word; lets us make line breaks
+ # at any char.
+ words = line
+
+ # When re-joining words, no additional spacer is used
+ word_spacer = ""
+
+ # TODO: Don't break before 、。「」() etc.
+ # TODO: If English terms are embedded, don't break mid-word
+
+ else:
+ # Separate words by any whitespace (spaces, line breaks, etc)
+ words = line.split()
+
+ # When re-joining words, separate with a space char
+ word_spacer = " "
+
if not words:
# It's a blank line
- _add_text_line("", 0)
+ _add_text_line("", 0, 0)
else:
while words:
- (index, tw) = _binary_len_search(0, len(words))
- _add_text_line(" ".join(words[0:index]), tw)
+ (index, tw, px_below_baseline) = _binary_len_search(0, len(words), word_spacer=word_spacer)
+ _add_text_line(word_spacer.join(words[0:index]), tw, px_below_baseline)
words = words[index:]
return text_lines
@@ -1910,8 +1927,7 @@ def reflow_text_into_pages(text: str,
height: int,
font_name=GUIConstants.get_body_font_name(),
font_size=GUIConstants.get_body_font_size(),
- line_spacer: int = GUIConstants.BODY_LINE_SPACING,
- allow_text_overflow: bool=False) -> list[str]:
+ line_spacer: int = GUIConstants.BODY_LINE_SPACING) -> list[str]:
"""
Invokes `reflow_text_for_width` above to convert long text into width-limited
individual text lines and then calculates how many lines will fit on a "page" and
@@ -1922,8 +1938,7 @@ def reflow_text_into_pages(text: str,
reflowed_lines_dicts = reflow_text_for_width(text=text,
width=width,
font_name=font_name,
- font_size=font_size,
- allow_text_overflow=allow_text_overflow)
+ font_size=font_size)
lines = []
for line_dict in reflowed_lines_dicts:
@@ -1950,3 +1965,47 @@ def reflow_text_into_pages(text: str,
pages.append("\n".join(lines[i:i+lines_per_page]))
return pages
+
+
+def resize_image_to_fill(img: Image, target_size_x: int, target_size_y: int, sampling_method=Image.Resampling.NEAREST) -> Image:
+ """
+ Resizes the image to fill the target size, cropping the image if necessary.
+ """
+ if img.width == target_size_x and img.height == target_size_y:
+ # No need to resize
+ return img
+
+ # if the image aspect ratio doesn't match the render area, we
+ # need to provide an aspect ratio-aware crop box.
+ render_aspect_ratio = target_size_x / target_size_y
+ source_frame_aspect_ratio = img.width / img.height
+ if render_aspect_ratio > source_frame_aspect_ratio:
+ # Render surface is wider than the source frame; preserve
+ # the width but crop the height
+ cropped_height = (img.width * target_size_y / target_size_x)
+ box = (
+ 0,
+ int((img.height - cropped_height)/2),
+ img.width,
+ img.height - int((img.height - cropped_height)/2),
+ )
+
+ elif render_aspect_ratio < source_frame_aspect_ratio:
+ # Render surface is taller than the source frame; preserve
+ # the height but crop the width
+ box = (
+ int((img.width - img.height * target_size_x / target_size_y) / 2),
+ 0,
+ int(img.width - (img.width - img.height * target_size_x / target_size_y) / 2),
+ img.height,
+ )
+
+ else:
+ # Render surface and source frame are the same aspect ratio
+ box = None
+
+ return img.resize(
+ (target_size_x, target_size_y),
+ resample=sampling_method,
+ box=box,
+ )
\ No newline at end of file
diff --git a/src/seedsigner/gui/keyboard.py b/src/seedsigner/gui/keyboard.py
index bc679ec5..eeca5ca3 100644
--- a/src/seedsigner/gui/keyboard.py
+++ b/src/seedsigner/gui/keyboard.py
@@ -34,10 +34,34 @@ class Keyboard:
KEY_BACKSPACE = {
"code": "DEL",
- "letter": SeedSignerIconConstants.DELETE,
- "font": ICON_KEY_FONT,
- "size": 2,
+ "letter": SeedSignerIconConstants.DELETE,
+ "font": ICON_KEY_FONT,
+ "size": 3,
}
+ KEY_BACKSPACE_2 = {
+ "code": "DEL",
+ "letter": SeedSignerIconConstants.DELETE,
+ "font": ICON_KEY_FONT,
+ "size": 2,
+ }
+ KEY_BACKSPACE_4 = {
+ "code": "DEL",
+ "letter": SeedSignerIconConstants.DELETE,
+ "font": ICON_KEY_FONT,
+ "size": 4,
+ }
+ KEY_BACKSPACE_5 = {
+ "code": "DEL",
+ "letter": SeedSignerIconConstants.DELETE,
+ "font": ICON_KEY_FONT,
+ "size": 5,
+ }
+ KEY_BACKSPACE_6 = {
+ "code": "DEL",
+ "letter": SeedSignerIconConstants.DELETE,
+ "font": ICON_KEY_FONT,
+ "size": 6,
+ }
KEY_SPACE = {
"code": "SPACE",
@@ -51,18 +75,18 @@ class Keyboard:
"font": ICON_KEY_FONT,
"size": 2,
}
- KEY_SPACE_3 = {
- "code": "SPACE",
- "letter": SeedSignerIconConstants.SPACE,
- "font": ICON_KEY_FONT,
- "size": 3,
- }
- KEY_SPACE_4 = {
- "code": "SPACE",
- "letter": SeedSignerIconConstants.SPACE,
- "font": ICON_KEY_FONT,
- "size": 4,
- }
+ # KEY_SPACE_3 = { # Unused definitions
+ # "code": "SPACE",
+ # "letter": SeedSignerIconConstants.SPACE,
+ # "font": ICON_KEY_FONT,
+ # "size": 3,
+ # }
+ # KEY_SPACE_4 = {
+ # "code": "SPACE",
+ # "letter": SeedSignerIconConstants.SPACE,
+ # "font": ICON_KEY_FONT,
+ # "size": 4,
+ # }
KEY_SPACE_5 = {
"code": "SPACE",
"letter": SeedSignerIconConstants.SPACE,
@@ -198,7 +222,6 @@ class Keyboard:
self.auto_wrap = auto_wrap
self.background_color = GUIConstants.BUTTON_BACKGROUND_COLOR
self.deactivated_background_color = GUIConstants.BACKGROUND_COLOR
- self.additional_key_deactivated_background_color = GUIConstants.BACKGROUND_COLOR
self.highlight_color = highlight_color
# Does the specified layout work?
@@ -230,7 +253,6 @@ class Keyboard:
self.y_gap = 2
self.key_height = int((rect[3] - rect[1]) / rows) - self.y_gap
self.height = rows * (self.key_height) + (rows - 1) * self.y_gap
- self.additional_key_entered_from_x = None
# Two-dimensional list of Key obj row data
self.keys = []
@@ -385,16 +407,12 @@ class Keyboard:
next_y += 1
- def update_from_input(self, input, enter_from=None):
+ def update_from_input(self, input):
"""
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`.
@@ -565,7 +583,6 @@ class TextEntryDisplay(TextEntryDisplayConstants):
if self.cursor_mode == TextEntryDisplay.CURSOR_MODE__BLOCK:
cursor_block_width = 18
- cursor_block_height = 33
# Draw n-1 of the selected letters
(left, top, right, bottom) = self.font.getbbox(self.cur_text[:-1], anchor="ls")
diff --git a/src/seedsigner/gui/renderer.py b/src/seedsigner/gui/renderer.py
index db0a4eb5..e572964e 100644
--- a/src/seedsigner/gui/renderer.py
+++ b/src/seedsigner/gui/renderer.py
@@ -1,7 +1,9 @@
from PIL import Image, ImageDraw
from threading import Lock
-from seedsigner.hardware.ST7789 import ST7789
+from seedsigner.hardware.displays.display_driver import ALL_DISPLAY_TYPES, DISPLAY_TYPE__ILI9341, DISPLAY_TYPE__ILI9486, DISPLAY_TYPE__ST7789, DisplayDriverFactory
+from seedsigner.models.settings import Settings
+from seedsigner.models.settings_definition import SettingsConstants
from seedsigner.models.singleton import ConfigurableSingleton
@@ -16,25 +18,60 @@ class Renderer(ConfigurableSingleton):
lock = Lock()
+ @property
+ def is_screenshot_generator(self) -> bool:
+ return False
+
+
@classmethod
def configure_instance(cls):
# Instantiate the one and only Renderer instance
renderer = cls.__new__(cls)
cls._instance = renderer
- # Eventually we'll be able to plug in other display controllers
- renderer.disp = ST7789()
- renderer.canvas_width = renderer.disp.width
- renderer.canvas_height = renderer.disp.height
+ renderer.initialize_display()
- renderer.canvas = Image.new('RGB', (renderer.canvas_width, renderer.canvas_height))
- renderer.draw = ImageDraw.Draw(renderer.canvas)
+
+ def initialize_display(self):
+ # May be called while already running with a previous display driver; must
+ # prevent any other screen writes while we're changing the display driver.
+ self.lock.acquire()
+
+ display_config = Settings.get_instance().get_value(SettingsConstants.SETTING__DISPLAY_CONFIGURATION, default_if_none=True)
+ self.display_type = display_config.split("_")[0]
+ if self.display_type not in ALL_DISPLAY_TYPES:
+ raise Exception(f"Invalid display type: {self.display_type}")
+
+ width, height = display_config.split("_")[1].split("x")
+
+ if self.disp:
+ # Existing instances might need to close resources like pwm
+ self.disp.cleanup()
+
+ self.disp = DisplayDriverFactory.instantiate_display_driver(self.display_type, width=int(width), height=int(height))
+
+ if Settings.get_instance().get_value(SettingsConstants.SETTING__DISPLAY_COLOR_INVERTED, default_if_none=True) == SettingsConstants.OPTION__ENABLED:
+ self.disp.invert()
+
+ if self.display_type == DISPLAY_TYPE__ST7789:
+ self.canvas_width = self.disp.width
+ self.canvas_height = self.disp.height
+
+ elif self.display_type in [DISPLAY_TYPE__ILI9341, DISPLAY_TYPE__ILI9486]:
+ # Swap for the natively portrait-oriented displays
+ self.canvas_width = self.disp.height
+ self.canvas_height = self.disp.width
+
+ self.canvas = Image.new('RGB', (self.canvas_width, self.canvas_height))
+ self.draw = ImageDraw.Draw(self.canvas)
+
+ self.lock.release()
def show_image(self, image=None, alpha_overlay=None, show_direct=False):
if show_direct:
# Use the incoming image as the canvas and immediately render
- self.disp.ShowImage(image, 0, 0)
+ self.disp.show_image(image, 0, 0)
return
if alpha_overlay:
@@ -46,7 +83,7 @@ class Renderer(ConfigurableSingleton):
# Always write to the current canvas, rather than trying to replace it
self.canvas.paste(image)
- self.disp.ShowImage(self.canvas, 0, 0)
+ self.disp.show_image(self.canvas, 0, 0)
def show_image_pan(self, image, start_x, start_y, end_x, end_y, rate, alpha_overlay=None):
@@ -80,7 +117,7 @@ class Renderer(ConfigurableSingleton):
# Always keep a copy of the current display in the canvas
self.canvas.paste(crop)
- self.disp.ShowImage(crop, 0, 0)
+ self.disp.show_image(crop, 0, 0)
diff --git a/src/seedsigner/gui/screens/psbt_screens.py b/src/seedsigner/gui/screens/psbt_screens.py
index aa447ed7..973d3ecb 100644
--- a/src/seedsigner/gui/screens/psbt_screens.py
+++ b/src/seedsigner/gui/screens/psbt_screens.py
@@ -29,12 +29,9 @@ class PSBTOverviewScreen(ButtonListScreen):
def __post_init__(self):
# Customize defaults
- self.title = _("Review PSBT")
+ self.title = _("Review Transaction")
self.is_bottom_list = True
- self.button_data = [ButtonOption("Review Details")]
-
- # This screen can take a while to load while parsing the PSBT
- self.show_loading_screen = True
+ self.button_data = [ButtonOption("Review details")]
super().__post_init__()
@@ -478,8 +475,8 @@ class PSBTMathScreen(ButtonListScreen):
def __post_init__(self):
# Customize defaults
- self.title = _("PSBT Math")
- self.button_data = [ButtonOption("Review Recipients")]
+ self.title = _("Transaction Math")
+ self.button_data = [ButtonOption("Review recipients")]
self.is_bottom_list = True
super().__post_init__()
@@ -668,40 +665,47 @@ class PSBTChangeDetailsScreen(ButtonListScreen):
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
))
+ screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
+
+ if self.is_change_derivation_path :
+ # TRANSLATOR_NOTE: Describes the address type (change or receive)
+ addr_type = _("change address")
+ else:
+ addr_type = _("receive address")
+
+ # TRANSLATOR_NOTE: Symbol for index number, e.g. "address #3"
+ index_num_symbol = _("#")
+
+ # note: NOT marking this for translation, hoping that the var ordering will not
+ # need to change in other languages.
+ value_text = f"{addr_type} {index_num_symbol}{self.derivation_path_addr_index}"
+ self.components.append(TextArea(
+ text=value_text,
+ font_color=GUIConstants.LABEL_FONT_COLOR,
+ font_size=GUIConstants.LABEL_FONT_SIZE,
+ is_text_centered=True,
+ screen_x=GUIConstants.EDGE_PADDING,
+ screen_y=screen_y,
+ ))
+
self.components.append(FormattedAddress(
screen_y=self.components[-1].screen_y + self.components[-1].height,
address=self.address,
max_lines=1,
))
- screen_y = self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING
-
- change_type = _("Multisig") if self.is_multisig else self.fingerprint
-
- if self.is_change_derivation_path :
- addr_type = _("Change")
- else:
- # TRANSLATOR_NOTE: Abbreviation for receive address
- addr_type = _("Addr")
-
- value_text = "{}: {} #{}".format(change_type, addr_type, self.derivation_path_addr_index)
- self.components.append(IconTextLine(
- value_text=value_text,
- icon_name=SeedSignerIconConstants.FINGERPRINT,
- icon_color=GUIConstants.INFO_COLOR,
- is_text_centered=False,
- screen_x=GUIConstants.EDGE_PADDING,
- screen_y=screen_y,
- ))
-
if self.is_change_addr_verified:
+ # How much empty space is left between the bottom of the addr and the first button?
+ available_y = self.buttons[0].screen_y - (self.components[-1].screen_y + self.components[-1].height)
+
self.components.append(IconTextLine(
icon_name=SeedSignerIconConstants.SUCCESS,
icon_color=GUIConstants.SUCCESS_COLOR,
value_text=_("Address verified!"),
- is_text_centered=False,
+ is_text_centered=True,
screen_x=GUIConstants.EDGE_PADDING,
- screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING,
+ screen_y=self.components[-1].screen_y + self.components[-1].height,
+ height=available_y, # Let the component auto-center vertically
))
@@ -722,7 +726,6 @@ class PSBTOpReturnScreen(ButtonListScreen):
text=self.op_return_data.decode(errors="strict"), # "strict" is a good enough heuristic to decide if it's human readable
font_size=GUIConstants.get_top_nav_title_font_size(),
is_text_centered=True,
- allow_text_overflow=True,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
height=self.buttons[0].screen_y - self.top_nav.height - 2*GUIConstants.COMPONENT_PADDING,
))
@@ -763,7 +766,7 @@ class PSBTOpReturnScreen(ButtonListScreen):
class PSBTFinalizeScreen(ButtonListScreen):
def __post_init__(self):
# Customize defaults
- self.title = _("Sign PSBT")
+ self.title = _("Sign Transaction")
self.is_bottom_list = True
super().__post_init__()
diff --git a/src/seedsigner/gui/screens/scan_screens.py b/src/seedsigner/gui/screens/scan_screens.py
index c030fef4..44f89436 100644
--- a/src/seedsigner/gui/screens/scan_screens.py
+++ b/src/seedsigner/gui/screens/scan_screens.py
@@ -5,7 +5,7 @@ from gettext import gettext as _
from PIL import Image, ImageDraw
from seedsigner.gui import renderer
-from seedsigner.gui.components import GUIConstants, Fonts
+from seedsigner.gui.components import GUIConstants, Fonts, resize_image_to_fill
from seedsigner.models.decode_qr import DecodeQR
from seedsigner.models.threads import BaseThread, ThreadsafeCounter
@@ -62,7 +62,6 @@ class ScanScreen(BaseScreen):
self.camera.start_video_stream_mode(resolution=self.resolution, framerate=self.framerate, format="rgb")
self.frames_decode_status = ThreadsafeCounter()
- self.frames_decoded_counter = ThreadsafeCounter()
self.threads.append(ScanScreen.LivePreviewThread(
decoder=self.decoder,
@@ -70,12 +69,11 @@ class ScanScreen(BaseScreen):
instructions_text=self.instructions_text,
render_rect=self.render_rect,
frame_decode_status=self.frames_decode_status,
- frames_decoded_counter=self.frames_decoded_counter,
))
class LivePreviewThread(BaseThread):
- def __init__(self, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: tuple[int,int,int,int], frame_decode_status: ThreadsafeCounter, frames_decoded_counter: ThreadsafeCounter):
+ def __init__(self, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: tuple[int,int,int,int], frame_decode_status: ThreadsafeCounter):
from seedsigner.hardware.camera import Camera
self.camera = Camera.get_instance()
@@ -87,8 +85,6 @@ class ScanScreen(BaseScreen):
else:
self.render_rect = (0, 0, self.renderer.canvas_width, self.renderer.canvas_height)
self.frame_decode_status = frame_decode_status
- self.frames_decoded_counter = frames_decoded_counter
- self.last_frame_decoded_count = self.frames_decoded_counter.cur_count
self.render_width = self.render_rect[2] - self.render_rect[0]
self.render_height = self.render_rect[3] - self.render_rect[1]
self.decoder_fps = "0.0"
@@ -97,46 +93,27 @@ class ScanScreen(BaseScreen):
def run(self):
- from timeit import default_timer as timer
-
instructions_font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_button_font_size())
# pre-calculate how big the animated QR percent display can be
- left, top, right, bottom = instructions_font.getbbox("100%")
+ (left, top, right, bottom) = instructions_font.getbbox("100%")
progress_text_width = right - left
- start_time = time.time()
num_frames = 0
- debug = False
- show_framerate = False # enable for debugging / testing
while self.keep_running:
frame = self.camera.read_video_stream(as_image=True)
if frame is not None:
num_frames += 1
- cur_time = time.time()
- cur_fps = num_frames / (cur_time - start_time)
scan_text = None
progress_percentage = self.decoder.get_percent_complete()
if progress_percentage == 0:
# We've just started scanning, no results yet
- if show_framerate:
- scan_text = f"{cur_fps:0.2f} | {self.decoder_fps}"
- else:
- scan_text = self.instructions_text
-
- elif debug:
- # Special debugging output for animated QRs
- scan_text = f"{self.decoder.get_percent_complete()}% | {self.decoder.get_percent_complete(weight_mixed_frames=True)}% (new)"
- if show_framerate:
- scan_text += f" {cur_fps:0.2f} | {self.decoder_fps}"
+ scan_text = self.instructions_text
with self.renderer.lock:
- if frame.width > self.render_width or frame.height > self.render_height:
- frame = frame.resize(
- (self.render_width, self.render_height),
- resample=Image.NEAREST # Use nearest neighbor for max speed
- )
+ # Use nearest neighbor resizing for max speed
+ frame = resize_image_to_fill(frame, self.render_width, self.render_height, sampling_method=Image.Resampling.NEAREST)
if scan_text:
# Note: shadowed text (adding a 'stroke' outline) can
@@ -219,7 +196,6 @@ class ScanScreen(BaseScreen):
# Render the dot to indicate successful QR frame read
indicator_size = 10
- self.last_frame_decoded_count = self.frames_decoded_counter.cur_count
status_color_map = {
ScanScreen.FRAME__ADDED_PART: GUIConstants.SUCCESS_COLOR,
ScanScreen.FRAME__REPEATED_PART: GUIConstants.INACTIVE_COLOR,
@@ -270,7 +246,6 @@ class ScanScreen(BaseScreen):
self.camera.stop_video_stream_mode()
break
- self.frames_decoded_counter.increment()
# Notify the live preview thread how our most recent decode went
if status == DecodeQRStatus.FALSE:
# Did not find anything to decode in the current frame
@@ -287,5 +262,5 @@ class ScanScreen(BaseScreen):
if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_RIGHT) or self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_LEFT):
self.camera.stop_video_stream_mode()
- break
+ return False
diff --git a/src/seedsigner/gui/screens/screen.py b/src/seedsigner/gui/screens/screen.py
index 47f6645e..fd6efbed 100644
--- a/src/seedsigner/gui/screens/screen.py
+++ b/src/seedsigner/gui/screens/screen.py
@@ -1,3 +1,4 @@
+import math
import logging
import time
@@ -277,6 +278,17 @@ class ButtonOption:
button_label_color: str = None
return_data: Any = None
active_button_label: str = None # Changes displayed button label when button is active
+ font_name: str = None # Optional override
+ font_size: int = None # Optional override
+
+
+
+@dataclass
+class ButtonOptionWithoutTranslation(ButtonOption):
+ """
+ Same as ButtonOption but does NOT translate button_label or active_button_label.
+ The labels are also not extracted for translation by babel.
+ """
@@ -286,15 +298,20 @@ class ButtonListScreen(BaseTopNavScreen):
selected_button: int = 0
is_button_text_centered: bool = True
is_bottom_list: bool = False
+
+ # Cannot define these class attrs w/the get_*_font_*() methods because the attrs will
+ # not be dynamically reinterpreted after initial class import.
button_font_name: str = None
button_font_size: int = None
+
button_selected_color: str = GUIConstants.ACCENT_COLOR
# Params for version of list used for Settings
Button_cls = Button
checked_buttons: List[int] = None
- # Enables returning w/buttons rendered at the same place
+ # Enables returning w/buttons rendered at the same place; default behavior will
+ # ensure the screen is at least scrolled to reveal the `selected_button`.
scroll_y_initial_offset: int = None
@@ -322,40 +339,46 @@ class ButtonListScreen(BaseTopNavScreen):
button_list_y = self.top_nav.height
self.has_scroll_arrows = True
+ # How many buttons fit on the screen before we need to start scrolling?
+ num_buttons_pre_scroll = math.floor((self.canvas_height - button_list_y - GUIConstants.EDGE_PADDING) / (button_height + GUIConstants.LIST_ITEM_PADDING))
+
+ # Force a scroll offset when necessary if none was provided
+ if self.selected_button + 1 > num_buttons_pre_scroll and not self.scroll_y_initial_offset:
+ # Scroll far enough to expose the selected button; +1 to account for the
+ # height of the target button itself!
+ self.scroll_y_initial_offset = (button_height + GUIConstants.LIST_ITEM_PADDING) * (self.selected_button - num_buttons_pre_scroll + 1)
+
self.buttons: List[Button] = []
for i, button_option in enumerate(self.button_data):
- icon_name = None
- icon_color = None
- right_icon_name = None
- button_label_color = None
-
- if type(button_option) == ButtonOption:
- button_label = button_option.button_label
- icon_name = button_option.icon_name
- icon_color = button_option.icon_color
- right_icon_name = button_option.right_icon_name
- button_label_color = button_option.button_label_color
- active_button_label = button_option.active_button_label
-
- else:
+ if not isinstance(button_option, ButtonOption):
raise Exception("Refactor to ButtonOption approach needed!")
+ if isinstance(button_option, ButtonOptionWithoutTranslation):
+ # Don't wrap labels in _()
+ button_label = button_option.button_label
+ active_button_label = button_option.active_button_label
+ else:
+ # Wrap labels in _() for just-in-time translations
+ button_label = _(button_option.button_label)
+ active_button_label = _(button_option.active_button_label)
+
+ # TODO: Refactor `Button` to optionally use ButtonOption directly?
button_kwargs = dict(
- text=_(button_label), # Wrap here for just-in-time translations
- active_text=_(active_button_label), # Wrap here for just-in-time translations
- icon_name=icon_name,
- icon_color=icon_color if icon_color else GUIConstants.BUTTON_FONT_COLOR,
+ text=button_label,
+ active_text=active_button_label,
+ icon_name=button_option.icon_name,
+ icon_color=button_option.icon_color if button_option.icon_color else GUIConstants.BUTTON_FONT_COLOR,
is_icon_inline=True,
- right_icon_name=right_icon_name,
+ right_icon_name=button_option.right_icon_name,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=button_list_y + i * (button_height + GUIConstants.LIST_ITEM_PADDING),
scroll_y=self.scroll_y_initial_offset if self.scroll_y_initial_offset is not None else 0,
width=self.canvas_width - (2 * GUIConstants.EDGE_PADDING),
height=button_height,
is_text_centered=self.is_button_text_centered,
- font_name=self.button_font_name,
- font_size=self.button_font_size,
- font_color=button_label_color if button_label_color else GUIConstants.BUTTON_FONT_COLOR,
+ font_name=button_option.font_name if button_option.font_name else self.button_font_name,
+ font_size=button_option.font_size if button_option.font_size else self.button_font_size,
+ font_color=button_option.button_label_color if button_option.button_label_color else GUIConstants.BUTTON_FONT_COLOR,
selected_color=self.button_selected_color,
is_scrollable_text=True, # We need to use the ScrollableText class for long button labels
)
@@ -366,7 +389,6 @@ class ButtonListScreen(BaseTopNavScreen):
if self.has_scroll_arrows:
self.arrow_half_width = 10
- self.cur_scroll_y = self.scroll_y_initial_offset if self.scroll_y_initial_offset is not None else 0
self.up_arrow_img = Image.new("RGBA", size=(2 * self.arrow_half_width, 8), color="black")
self.up_arrow_img_y = self.top_nav.height - 12
arrow_draw = ImageDraw.Draw(self.up_arrow_img)
@@ -553,8 +575,12 @@ class ButtonListScreen(BaseTopNavScreen):
@dataclass
class LargeButtonScreen(BaseTopNavScreen):
button_data: list = None
+
+ # Cannot define these class attrs w/the get_*_font_*() methods because the attrs will
+ # not be dynamically reinterpreted after initial class import.
button_font_name: str = None
button_font_size: int = None
+
button_selected_color: str = GUIConstants.ACCENT_COLOR
selected_button: int = 0
@@ -562,6 +588,7 @@ class LargeButtonScreen(BaseTopNavScreen):
if not self.button_font_name:
self.button_font_name = GUIConstants.get_button_font_name()
if not self.button_font_size:
+ # TODO: Define the +2 with a constant or via a formula (e.g. int(x * 1.1))
self.button_font_size = GUIConstants.get_button_font_size() + 2
super().__post_init__()
@@ -569,9 +596,11 @@ class LargeButtonScreen(BaseTopNavScreen):
if len(self.button_data) not in [2, 4]:
raise Exception("LargeButtonScreen only supports 2 or 4 buttons")
- # Maximize 2-across width; calc height with a 4:3 aspect ratio
+ # Maximize 2-across width
button_width = int((self.canvas_width - (2 * GUIConstants.EDGE_PADDING) - GUIConstants.COMPONENT_PADDING) / 2)
- button_height = int(button_width * (3.0 / 4.0))
+
+ # Maximize 2-row height
+ button_height = int((self.canvas_height - self.top_nav.height - (2 * GUIConstants.COMPONENT_PADDING) - GUIConstants.EDGE_PADDING) / 2)
# Vertically center the buttons
if len(self.button_data) == 2:
@@ -774,7 +803,6 @@ class QRDisplayScreen(BaseScreen):
width=int(rectangle_width/2),
screen_x=chevron_up_icon.screen_x + GUIConstants.ICON_INLINE_FONT_SIZE,
screen_y=chevron_up_icon.screen_y - 2, # -2 to account for Icon's positioning
- allow_text_overflow=False
).render()
# TRANSLATOR_NOTE: Decrease QR code screen brightness
@@ -792,7 +820,6 @@ class QRDisplayScreen(BaseScreen):
width=int(rectangle_width/2),
screen_x=chevron_down_icon.screen_x + GUIConstants.ICON_INLINE_FONT_SIZE,
screen_y=chevron_down_icon.screen_y - 2, # -2 to account for Icon's positioning
- allow_text_overflow=False
).render()
# Write our temp Image onto the main image
@@ -894,7 +921,6 @@ class LargeIconStatusScreen(ButtonListScreen):
text: str = "" # The body text of the screen
text_edge_padding: int = GUIConstants.EDGE_PADDING
button_data: list = None
- allow_text_overflow: bool = False
def __post_init__(self):
@@ -1020,7 +1046,7 @@ class WarningScreen(WarningEdgesMixin, LargeIconStatusScreen):
status_icon_name: str = SeedSignerIconConstants.WARNING
status_color: str = GUIConstants.WARNING_COLOR
status_headline: str = _mft("Privacy Leak!") # The colored text under the alert icon
- button_data: list = field(default_factory=lambda: [ButtonOption("I Understand")])
+ button_data: list = field(default_factory=lambda: [ButtonOption("I understand")])
@@ -1060,21 +1086,6 @@ class ResetScreen(BaseTopNavScreen):
-@dataclass
-class PowerOffScreen(BaseTopNavScreen):
- def __post_init__(self):
- self.title = _("Powering Off")
- self.show_back_button = False
- super().__post_init__()
-
- self.components.append(TextArea(
- text=_("Please wait about 30 seconds before disconnecting power."),
- screen_y=self.top_nav.height,
- height=self.canvas_height - self.top_nav.height,
- ))
-
-
-
@dataclass
class PowerOffNotRequiredScreen(BaseTopNavScreen):
def __post_init__(self):
@@ -1118,6 +1129,8 @@ class KeyboardScreen(BaseTopNavScreen):
return_after_n_chars: int = None
show_save_button: bool = False
initial_value: str = ""
+ from dataclasses import dataclass, field
+ custom_additional_keys: dict = field(default_factory=lambda: Keyboard.ADDITIONAL_KEYS)
def __post_init__(self):
if self.keyboard_font_size is None:
@@ -1176,6 +1189,7 @@ class KeyboardScreen(BaseTopNavScreen):
GUIConstants.EDGE_PADDING + self.keyboard_width,
keyboard_start_y + self.rows * self.key_height + (self.rows - 1) * 2
),
+ additional_keys=self.custom_additional_keys,
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
)
@@ -1214,6 +1228,8 @@ class KeyboardScreen(BaseTopNavScreen):
)
with self.renderer.lock:
+ # Track if we need to update the title after input changes
+ title_needs_update = False
# Check possible exit conditions
if self.top_nav.is_selected and input == HardwareButtonsConstants.KEY_PRESS:
return RET_CODE__BACK_BUTTON
@@ -1259,7 +1275,8 @@ class KeyboardScreen(BaseTopNavScreen):
if len(self.user_input) > 0:
self.user_input = self.user_input[:-1]
self.cursor_position -= 1
-
+ title_needs_update = True
+
elif input == HardwareButtonsConstants.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS:
# User has locked in the current letter
if self.keys_to_values:
@@ -1267,19 +1284,20 @@ class KeyboardScreen(BaseTopNavScreen):
ret_val = self.keys_to_values[ret_val]
self.user_input += ret_val
self.cursor_position += 1
+ title_needs_update = True
if self.cursor_position == self.return_after_n_chars:
return self.user_input
- # Render a new TextArea over the TopNav title bar
- if self.update_title():
- TextArea(
- text=self.title,
- font_name=GUIConstants.get_top_nav_title_font_name(),
- font_size=GUIConstants.get_top_nav_title_font_size(),
- height=self.top_nav.height,
- ).render()
- self.top_nav.render_buttons()
+ # Update the title if input changed (add or delete)
+ if title_needs_update and self.update_title():
+ TextArea(
+ text=self.title,
+ font_name=GUIConstants.get_top_nav_title_font_name(),
+ font_size=GUIConstants.get_top_nav_title_font_size(),
+ height=self.top_nav.height,
+ ).render()
+ self.top_nav.render_buttons()
elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN:
# Live joystick movement; haven't locked this new letter in yet.
diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py
index 08d1966b..0ab12311 100644
--- a/src/seedsigner/gui/screens/seed_screens.py
+++ b/src/seedsigner/gui/screens/seed_screens.py
@@ -31,15 +31,24 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen):
self.possible_alphabet = "abcdefghijklmnopqrstuvwxyz"
+ # Measure the width required to display the longest word in the English BIP-39
+ # wordlist.
+ # TODO: If we ever support other wordlist languages, adjust accordingly.
+ matches_list_highlight_font_name = GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME
+ matches_list_highlight_font_size = GUIConstants.get_button_font_size() + 4
+ (left, top, right, bottom) = Fonts.get_font(matches_list_highlight_font_name, matches_list_highlight_font_size).getbbox("mushroom", anchor="ls")
+ matches_list_max_text_width = right - left
+ matches_list_button_width = matches_list_max_text_width + 2*GUIConstants.COMPONENT_PADDING
+
# Set up the keyboard params
- self.keyboard_width = 128
+ self.keyboard_width = self.canvas_width - GUIConstants.EDGE_PADDING - matches_list_button_width
text_entry_display_y = self.top_nav.height
text_entry_display_height = 30
self.arrow_up_is_active = False
self.arrow_down_is_active = False
- # TODO: support other BIP39 languages/charsets
+ # TODO: support other BIP-39 languages/charsets
self.keyboard = Keyboard(
draw=self.image_draw,
charset=self.possible_alphabet,
@@ -51,6 +60,7 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen):
GUIConstants.EDGE_PADDING + self.keyboard_width,
self.canvas_height
),
+ additional_keys=[Keyboard.KEY_BACKSPACE_4],
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT]
)
@@ -78,7 +88,7 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen):
else:
self.keyboard.set_selected_key(selected_letter=self.letters[-1])
- self.matches_list_x = GUIConstants.EDGE_PADDING + self.keyboard.width + GUIConstants.COMPONENT_PADDING
+ self.matches_list_x = self.canvas_width - matches_list_button_width
self.matches_list_y = self.top_nav.height
self.highlighted_row_y = int((self.canvas_height - GUIConstants.BUTTON_HEIGHT)/2)
@@ -432,7 +442,6 @@ class SeedFinalizeScreen(ButtonListScreen):
@dataclass
class SeedOptionsScreen(ButtonListScreen):
fingerprint: str = None
- has_passphrase: bool = False
def __post_init__(self):
self.top_nav_icon_name = SeedSignerIconConstants.FINGERPRINT
@@ -445,24 +454,6 @@ class SeedOptionsScreen(ButtonListScreen):
-@dataclass
-class SeedBackupScreen(ButtonListScreen):
- has_passphrase: bool = False
-
- def __post_init__(self):
- self.title = _("Backup Seed")
- self.is_bottom_list = True
- super().__post_init__()
-
- if self.has_passphrase:
- self.components.append(TextArea(
- # TRANSLATOR_NOTE: Additional explainer for the two seed backup options (mnemonic phrase and SeedQR).
- text=_("Backups do not include your passphrase."),
- screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
- ))
-
-
-
@dataclass
class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen):
words: List[str] = None
@@ -557,6 +548,7 @@ class SeedBIP85SelectChildIndexScreen(KeyboardScreen):
self.cols = 5
self.keys_charset = "0123456789"
self.show_save_button = True
+ self.custom_additional_keys = [Keyboard.KEY_BACKSPACE_5]
super().__post_init__()
@@ -589,6 +581,7 @@ class SeedExportXpubCustomDerivationScreen(KeyboardScreen):
self.cols = 6
self.keys_charset = "/'0123456789"
self.show_save_button = True
+ self.custom_additional_keys = [Keyboard.KEY_BACKSPACE_6]
super().__post_init__()
@@ -599,13 +592,12 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen):
# Customize defaults
is_bottom_list: bool = True
fingerprint: str = None
- has_passphrase: bool = False
derivation_path: str = "m/84'/0'/0'"
xpub: str = "zpub6r..."
def __post_init__(self):
# Programmatically set up other args
- self.button_data = [ButtonOption("Export Xpub")]
+ self.button_data = [ButtonOption("Export xpub")]
self.title = _("Xpub Details")
# Initialize the base class
@@ -615,7 +607,7 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen):
self.fingerprint_line = IconTextLine(
icon_name=SeedSignerIconConstants.FINGERPRINT,
icon_color=GUIConstants.INFO_COLOR,
- # TRANSLATOR_NOTE: Short for "BIP32 Master Fingerprint"
+ # TRANSLATOR_NOTE: Short for "BIP-32 Master Fingerprint"
label_text=_("Fingerprint"),
value_text=self.fingerprint,
screen_x=GUIConstants.COMPONENT_PADDING,
@@ -634,12 +626,17 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen):
)
self.components.append(self.derivation_line)
+ font_name = GUIConstants.FIXED_WIDTH_FONT_NAME
+ font_size = GUIConstants.get_body_font_size() + 2
+ left, top, right, bottom = Fonts.get_font(font_name, font_size).getbbox("X")
+ char_width = right - left
+ num_chars = int((self.canvas_width - GUIConstants.ICON_FONT_SIZE - 2*GUIConstants.COMPONENT_PADDING) / char_width) - 3 # ellipsis
+
self.xpub_line = IconTextLine(
icon_name=FontAwesomeIconConstants.X,
icon_color=GUIConstants.INFO_COLOR,
- # TRANSLATOR_NOTE: Short for "BIP32 Extended Public Key"
label_text=_("Xpub"),
- value_text=f"{self.xpub[:18]}...",
+ value_text=f"{self.xpub[:num_chars]}...",
font_name=GUIConstants.FIXED_WIDTH_FONT_NAME,
font_size=GUIConstants.get_body_font_size() + 2,
screen_x=GUIConstants.COMPONENT_PADDING,
@@ -763,7 +760,7 @@ class SeedAddPassphraseScreen(BaseTopNavScreen):
Keyboard.KEY_SPACE_2,
Keyboard.KEY_CURSOR_LEFT,
Keyboard.KEY_CURSOR_RIGHT,
- Keyboard.KEY_BACKSPACE
+ Keyboard.KEY_BACKSPACE_2
],
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
@@ -784,7 +781,7 @@ class SeedAddPassphraseScreen(BaseTopNavScreen):
Keyboard.KEY_SPACE_2,
Keyboard.KEY_CURSOR_LEFT,
Keyboard.KEY_CURSOR_RIGHT,
- Keyboard.KEY_BACKSPACE
+ Keyboard.KEY_BACKSPACE_2
],
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
@@ -997,10 +994,10 @@ class SeedAddPassphraseScreen(BaseTopNavScreen):
pass
elif cursor_position == len(self.passphrase):
self.passphrase = self.passphrase[:-1]
+ cursor_position -= 1
else:
self.passphrase = self.passphrase[:cursor_position - 1] + self.passphrase[cursor_position:]
-
- cursor_position -= 1
+ cursor_position -= 1
elif ret_val == Keyboard.KEY_CURSOR_LEFT["code"]:
cursor_position -= 1
@@ -1115,7 +1112,6 @@ class SeedReviewPassphraseScreen(ButtonListScreen):
font_color="orange",
is_text_centered=True,
screen_y=screen_y,
- allow_text_overflow=True
))
screen_y += char_height + 2
@@ -1181,68 +1177,81 @@ class SeedTranscribeSeedQRWholeQRScreen(WarningEdgesMixin, ButtonListScreen):
@dataclass
class SeedTranscribeSeedQRZoomedInScreen(BaseScreen):
+ """
+ QR codes are defined by the number of "modules" (squares), e.g. 21x21 modules.
+
+ Each module will be rendered as a square of pixels, e.g. 24x24 pixels.
+
+ In this Screen, a "zone" will mean a square module area, e.g. 5x5 modules, that
+ corresponds to the SeedQR templates which include zone guidelines and labels
+ (e.g. "B-3").
+ """
qr_data: str = None
num_modules: int = None
- initial_block_x: int = 0
- initial_block_y: int = 0
+ initial_zone_x: int = 0
+ initial_zone_y: int = 0
def __post_init__(self):
super().__post_init__()
# Render an oversized QR code that we can view up close
- self.pixels_per_block = 24
+ self.pixels_per_module = 24
# Border must accommodate the 3 blocks outside the center 5x5 mask plus up to
# 1 empty block inside the 5x5 mask (29x29 has a 4-block final col/row).
- self.qr_border = 4
+ self.num_qr_border_modules = 4
if self.num_modules == 21:
# Optimize for 21x21
- self.qr_blocks_per_zoom = 7
+ self.modules_per_zone = 7 # i.e. a 7x7 group of modules
else:
- self.qr_blocks_per_zoom = 5
+ self.modules_per_zone = 5
- self.qr_width = (self.qr_border + self.num_modules + self.qr_border) * self.pixels_per_block
- self.height = self.qr_width
+ total_qr_image_width = (self.num_qr_border_modules + self.num_modules + self.num_qr_border_modules) * self.pixels_per_module
qr = QR()
- self.qr_image = qr.qrimage(
+ self.qr_image: Image = qr.qrimage(
self.qr_data,
- width=self.qr_width,
- height=self.height,
- border=self.qr_border,
+ width=total_qr_image_width,
+ height=total_qr_image_width, # QR image is always square
+ border=self.num_qr_border_modules,
style=QR.STYLE__ROUNDED
- ).convert("RGBA")
+ # ).convert("RGBA")
+ )
- # Render gridlines but leave the 1-block border as-is
- draw = ImageDraw.Draw(self.qr_image)
- for i in range(self.qr_border, math.floor(self.qr_width/self.pixels_per_block) - self.qr_border):
- draw.line((i * self.pixels_per_block, self.qr_border * self.pixels_per_block, i * self.pixels_per_block, self.height - self.qr_border * self.pixels_per_block), fill="#bbb")
- draw.line((self.qr_border * self.pixels_per_block, i * self.pixels_per_block, self.qr_width - self.qr_border * self.pixels_per_block, i * self.pixels_per_block), fill="#bbb")
+ # Render gridlines over the QR code but don't draw on its external white border
+ qr_image_draw = ImageDraw.Draw(self.qr_image)
+ for i in range(self.num_qr_border_modules, math.floor(self.qr_image.width/self.pixels_per_module) - self.num_qr_border_modules):
+ qr_image_draw.line((i * self.pixels_per_module, self.num_qr_border_modules * self.pixels_per_module, i * self.pixels_per_module, self.qr_image.height - self.num_qr_border_modules * self.pixels_per_module), fill="#bbb")
+ qr_image_draw.line((self.num_qr_border_modules * self.pixels_per_module, i * self.pixels_per_module, self.qr_image.width - self.num_qr_border_modules * self.pixels_per_module, i * self.pixels_per_module), fill="#bbb")
- # Prep the semi-transparent mask overlay
- # make a blank image for the overlay, initialized to transparent
- self.block_mask = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0))
- draw = ImageDraw.Draw(self.block_mask)
-
- self.mask_width = int((self.canvas_width - self.qr_blocks_per_zoom * self.pixels_per_block)/2)
- self.mask_height = int((self.canvas_height - self.qr_blocks_per_zoom * self.pixels_per_block)/2)
+ # Make a blank semi-transparent image for the overlay, initially across the
+ # entire canvas.
mask_rgba = (0, 0, 0, 226)
- draw.rectangle((0, 0, self.canvas_width, self.mask_height), fill=mask_rgba)
- draw.rectangle((0, self.canvas_height - self.mask_height - 1, self.canvas_width, self.canvas_height), fill=mask_rgba)
- draw.rectangle((0, self.mask_height, self.mask_width, self.canvas_height - self.mask_height), fill=mask_rgba)
- draw.rectangle((self.canvas_width - self.mask_width - 1, self.mask_height, self.canvas_width, self.canvas_height - self.mask_height), fill=mask_rgba)
+ self.zone_mask = Image.new("RGBA", (self.canvas_width, self.canvas_height), mask_rgba)
+ zone_mask_draw = ImageDraw.Draw(self.zone_mask)
- # Draw a box around the cutout portion of the mask for better visibility
- draw.line((self.mask_width, self.mask_height, self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR)
- draw.line((self.canvas_width - self.mask_width, self.mask_height, self.canvas_width - self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR)
- draw.line((self.mask_width, self.mask_height, self.canvas_width - self.mask_width, self.mask_height), fill=GUIConstants.ACCENT_COLOR)
- draw.line((self.mask_width, self.canvas_height - self.mask_height, self.canvas_width - self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR)
+ # Now punch a hole in the center of the mask to highlight the current zone with
+ # an accent outline.
+ # The `zone_mask_offset_*` vars are the top left xy coords of the mask.
+ self.zone_mask_offset_x = int((self.canvas_width - (self.modules_per_zone * self.pixels_per_module))/2)
+ self.zone_mask_offset_y = int((self.canvas_height - (self.modules_per_zone * self.pixels_per_module))/2)
+ zone_mask_draw.rectangle(
+ (
+ self.zone_mask_offset_x,
+ self.zone_mask_offset_y,
+ self.canvas_width - self.zone_mask_offset_x,
+ self.canvas_height - self.zone_mask_offset_y
+ ),
+ fill=(255, 255, 255, 0), # fully transparent mask area
+ outline=GUIConstants.ACCENT_COLOR,
+ width=1
+ )
msg = _("click to exit")
font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_body_font_size())
(left, top, right, bottom) = font.getbbox(msg, anchor="ls")
msg_height = -1 * top + GUIConstants.COMPONENT_PADDING
msg_width = right + 2*GUIConstants.COMPONENT_PADDING
- draw.rectangle(
+ zone_mask_draw.rectangle(
(
int((self.canvas_width - msg_width)/2),
self.canvas_height - msg_height,
@@ -1251,7 +1260,7 @@ class SeedTranscribeSeedQRZoomedInScreen(BaseScreen):
),
fill=GUIConstants.BACKGROUND_COLOR,
)
- draw.text(
+ zone_mask_draw.text(
(int(self.canvas_width/2), self.canvas_height - int(GUIConstants.COMPONENT_PADDING/2)),
msg,
fill=GUIConstants.BODY_FONT_COLOR,
@@ -1261,103 +1270,103 @@ class SeedTranscribeSeedQRZoomedInScreen(BaseScreen):
- def draw_block_labels(self):
- # Create overlay for block labels (e.g. "D-5")
- block_labels_x = ["1", "2", "3", "4", "5", "6"]
- block_labels_y = ["A", "B", "C", "D", "E", "F"]
+ def draw_zone_labels(self):
+ # Create overlay for zone labels (e.g. "D-5")
+ # TODO: Discuss w/translators if these zone labels need to be translated; would
+ # trigger a secondary need to have translated SeedQR printable templates as well.
+ zone_labels_x = ["1", "2", "3", "4", "5", "6"]
+ zone_labels_y = ["A", "B", "C", "D", "E", "F"]
- block_labels = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0))
- draw = ImageDraw.Draw(block_labels)
- draw.rectangle((self.mask_width, 0, self.canvas_width - self.mask_width, self.pixels_per_block), fill=GUIConstants.ACCENT_COLOR)
- draw.rectangle((0, self.mask_height, self.pixels_per_block, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR)
+ zone_labels = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0))
+ zone_labels_draw = ImageDraw.Draw(zone_labels)
+ zone_labels_draw.rectangle((self.zone_mask_offset_x, 0, self.canvas_width - self.zone_mask_offset_x, self.pixels_per_module), fill=GUIConstants.ACCENT_COLOR)
+ zone_labels_draw.rectangle((0, self.zone_mask_offset_y, self.pixels_per_module, self.canvas_height - self.zone_mask_offset_y), fill=GUIConstants.ACCENT_COLOR)
label_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, 28)
- x_label = block_labels_x[self.cur_block_x]
+ x_label = zone_labels_x[self.cur_zone_x]
(left, top, right, bottom) = label_font.getbbox(x_label, anchor="ls")
x_label_height = -1 * top
- draw.text(
- (int(self.canvas_width/2), self.pixels_per_block - int((self.pixels_per_block - x_label_height)/2)),
+ zone_labels_draw.text(
+ (int(self.canvas_width/2), self.pixels_per_module - int((self.pixels_per_module - x_label_height)/2)),
text=x_label,
fill=GUIConstants.BUTTON_SELECTED_FONT_COLOR,
font=label_font,
anchor="ms", # Middle, baSeline
)
- y_label = block_labels_y[self.cur_block_y]
+ y_label = zone_labels_y[self.cur_zone_y]
(left, top, right, bottom) = label_font.getbbox(y_label, anchor="ls")
y_label_height = -1 * top
- draw.text(
- (int(self.pixels_per_block/2), int((self.canvas_height + y_label_height) / 2)),
+ zone_labels_draw.text(
+ (int(self.pixels_per_module/2), int((self.canvas_height + y_label_height) / 2)),
text=y_label,
fill=GUIConstants.BUTTON_SELECTED_FONT_COLOR,
font=label_font,
anchor="ms", # Middle, baSeline
)
- return block_labels
+ return zone_labels
def _render(self):
- # Track our current coordinates for the upper left corner of our view
- self.cur_block_x = self.initial_block_x
- self.cur_block_y = self.initial_block_y
- self.cur_x = (self.cur_block_x * self.qr_blocks_per_zoom * self.pixels_per_block) + self.qr_border * self.pixels_per_block - self.mask_width
- self.cur_y = (self.cur_block_y * self.qr_blocks_per_zoom * self.pixels_per_block) + self.qr_border * self.pixels_per_block - self.mask_height
- self.next_x = self.cur_x
- self.next_y = self.cur_y
+ # Track our current zone-level (macro-module) position and our actual pixel
+ # coordinates as we pan around across the QR code image.
+ self.cur_zone_x = self.initial_zone_x
+ self.cur_zone_y = self.initial_zone_y
+ self.cur_pixel_x = (self.cur_zone_x * self.modules_per_zone * self.pixels_per_module) + self.num_qr_border_modules * self.pixels_per_module - self.zone_mask_offset_x
+ self.cur_pixel_y = (self.cur_zone_y * self.modules_per_zone * self.pixels_per_module) + self.num_qr_border_modules * self.pixels_per_module - self.zone_mask_offset_y
+ self.next_pixel_x = self.cur_pixel_x
+ self.next_pixel_y = self.cur_pixel_y
- block_labels = self.draw_block_labels()
+ zone_labels = self.draw_zone_labels()
self.renderer.show_image(
- self.qr_image.crop((self.cur_x, self.cur_y, self.cur_x + self.canvas_width, self.cur_y + self.canvas_height)),
- alpha_overlay=Image.alpha_composite(self.block_mask, block_labels)
+ self.qr_image.crop((self.cur_pixel_x, self.cur_pixel_y, self.cur_pixel_x + self.canvas_width, self.cur_pixel_y + self.canvas_height)),
+ alpha_overlay=Image.alpha_composite(self.zone_mask, zone_labels)
)
def _run(self):
while True:
input = self.hw_inputs.wait_for(HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN + HardwareButtonsConstants.KEYS__ANYCLICK)
- if input == HardwareButtonsConstants.KEY_RIGHT:
- self.next_x = self.cur_x + self.qr_blocks_per_zoom * self.pixels_per_block
- self.cur_block_x += 1
- if self.next_x > self.qr_width - self.canvas_width:
- self.next_x = self.cur_x
- self.cur_block_x -= 1
- elif input == HardwareButtonsConstants.KEY_LEFT:
- self.next_x = self.cur_x - self.qr_blocks_per_zoom * self.pixels_per_block
- self.cur_block_x -= 1
- if self.next_x < 0:
- self.next_x = self.cur_x
- self.cur_block_x += 1
- elif input == HardwareButtonsConstants.KEY_DOWN:
- self.next_y = self.cur_y + self.qr_blocks_per_zoom * self.pixels_per_block
- self.cur_block_y += 1
- if self.next_y > self.height - self.canvas_height:
- self.next_y = self.cur_y
- self.cur_block_y -= 1
- elif input == HardwareButtonsConstants.KEY_UP:
- self.next_y = self.cur_y - self.qr_blocks_per_zoom * self.pixels_per_block
- self.cur_block_y -= 1
- if self.next_y < 0:
- self.next_y = self.cur_y
- self.cur_block_y += 1
- elif input in HardwareButtonsConstants.KEYS__ANYCLICK:
+
+ if input in HardwareButtonsConstants.KEYS__ANYCLICK:
+ # User clicked to exit
return
- # Create overlay for block labels (e.g. "D-5")
- block_labels = self.draw_block_labels()
+ elif input == HardwareButtonsConstants.KEY_RIGHT and self.cur_zone_x + 1 < math.ceil(self.num_modules/self.modules_per_zone):
+ self.next_pixel_x = self.cur_pixel_x + self.modules_per_zone * self.pixels_per_module
+ self.cur_zone_x += 1
- if self.cur_x != self.next_x or self.cur_y != self.next_y:
- with self.renderer.lock:
- self.renderer.show_image_pan(
- self.qr_image,
- self.cur_x, self.cur_y, self.next_x, self.next_y,
- rate=self.pixels_per_block,
- alpha_overlay=Image.alpha_composite(self.block_mask, block_labels)
- )
- self.cur_x = self.next_x
- self.cur_y = self.next_y
+ elif input == HardwareButtonsConstants.KEY_LEFT and self.cur_zone_x - 1 >= 0:
+ self.next_pixel_x = self.cur_pixel_x - self.modules_per_zone * self.pixels_per_module
+ self.cur_zone_x -= 1
+
+ elif input == HardwareButtonsConstants.KEY_DOWN and self.cur_zone_y + 1 < math.ceil(self.num_modules/self.modules_per_zone):
+ self.next_pixel_y = self.cur_pixel_y + self.modules_per_zone * self.pixels_per_module
+ self.cur_zone_y += 1
+
+ elif input == HardwareButtonsConstants.KEY_UP and self.cur_zone_y - 1 >= 0:
+ self.next_pixel_y = self.cur_pixel_y - self.modules_per_zone * self.pixels_per_module
+ self.cur_zone_y -= 1
+
+ else:
+ # User selected a direction that we can't advance any further
+ continue
+
+ # Create overlay for zone labels (e.g. "D-5")
+ zone_labels = self.draw_zone_labels()
+
+ with self.renderer.lock:
+ self.renderer.show_image_pan(
+ self.qr_image,
+ self.cur_pixel_x, self.cur_pixel_y, self.next_pixel_x, self.next_pixel_y,
+ rate=self.pixels_per_module,
+ alpha_overlay=Image.alpha_composite(self.zone_mask, zone_labels)
+ )
+ self.cur_pixel_x = self.next_pixel_x
+ self.cur_pixel_y = self.next_pixel_y
@@ -1614,7 +1623,6 @@ class SeedSignMessageConfirmMessageScreen(ButtonListScreen):
text=self.sign_message_data["message"],
width=renderer.canvas_width - 2*GUIConstants.EDGE_PADDING,
height=message_height,
- allow_text_overflow=True,
)
self.sign_message_data["paged_message"] = paged
@@ -1633,7 +1641,6 @@ class SeedSignMessageConfirmMessageScreen(ButtonListScreen):
message_display = TextArea(
text=self.sign_message_data["paged_message"][self.page_num],
is_text_centered=False,
- allow_text_overflow=True,
screen_y=start_y,
)
self.components.append(message_display)
@@ -1649,7 +1656,7 @@ class SeedSignMessageConfirmAddressScreen(ButtonListScreen):
self.title = _("Confirm Address")
self.is_bottom_list = True
self.is_button_text_centered = True
- self.button_data = [ButtonOption("Sign Message")]
+ self.button_data = [ButtonOption("Sign message")]
super().__post_init__()
derivation_path_display = IconTextLine(
diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py
index c41eadfc..70a8d5e1 100644
--- a/src/seedsigner/gui/screens/settings_screens.py
+++ b/src/seedsigner/gui/screens/settings_screens.py
@@ -21,12 +21,10 @@ class SettingsEntryUpdateSelectionScreen(ButtonListScreen):
help_text: str = None
checked_buttons: List[int] = None
settings_entry_type: str = SettingsConstants.TYPE__ENABLED_DISABLED
- selected_button: int = 0
def __post_init__(self):
self.title = _("Settings")
self.is_bottom_list = True
- self.use_checked_selection_buttons = True
if self.settings_entry_type == SettingsConstants.TYPE__MULTISELECT:
self.Button_cls = CheckboxButton
else:
@@ -146,7 +144,11 @@ class IOTestScreen(BaseTopNavScreen):
outline_color=GUIConstants.ACCENT_COLOR,
is_scrollable_text=False, # Text has to dynamically update, can't use scrollable Button
)
- self.key2_button.text = " " # but default state is empty
+ if not self.renderer.is_screenshot_generator:
+ # The button text should be empty for its initial state ("Clear" doesn't make
+ # any sense until a test image is captured). But the screenshot generator
+ # should show the text so its translation can be reviewed.
+ self.key2_button.text = " "
self.components.append(self.key2_button)
self.key1_button = IconButton(
diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py
index 8fab9a16..e00d7914 100644
--- a/src/seedsigner/gui/screens/tools_screens.py
+++ b/src/seedsigner/gui/screens/tools_screens.py
@@ -4,12 +4,14 @@ from dataclasses import dataclass
from gettext import gettext as _
from typing import Any
from PIL.Image import Image
+from seedsigner.gui.renderer import Renderer
from seedsigner.hardware.camera import Camera
from seedsigner.gui.components import FontAwesomeIconConstants, Fonts, GUIConstants, IconTextLine, SeedSignerIconConstants, TextArea
-from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen, ButtonListScreen, KeyboardScreen
+from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen, ButtonListScreen, ButtonOption, KeyboardScreen
from seedsigner.hardware.buttons import HardwareButtonsConstants
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
+from seedsigner.gui.keyboard import Keyboard
@@ -19,7 +21,12 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
super().__post_init__()
self.camera = Camera.get_instance()
- self.camera.start_video_stream_mode(resolution=(self.canvas_width, self.canvas_height), framerate=24, format="rgb")
+
+ # If the stream is set to 320x240, we get pillarboxed frames (black bars on the
+ # sides). But passing in square dims gives us an edge-to-edge image.
+ # TODO: Figure out why (camera expecting frame dims of multiples other than 16?)
+ max_dimension = max(self.canvas_width, self.canvas_height)
+ self.camera.start_video_stream_mode(resolution=(max_dimension, max_dimension), framerate=24, format="rgb")
def _run(self):
@@ -36,13 +43,38 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
self.camera.stop_video_stream_mode()
return RET_CODE__BACK_BUTTON
- frame = self.camera.read_video_stream(as_image=True)
+ frame: Image = self.camera.read_video_stream(as_image=True)
if frame is None:
# Camera probably isn't ready yet
time.sleep(0.01)
continue
+ with self.renderer.lock:
+ # Account for the possibly different aspect ratio of the camera frame
+ # vs the display; crop any excess.
+ # TODO: This cropping may be unnecessary if the above TODO about the
+ # camera resolution is solved.
+ box = None
+ if self.canvas_width != frame.width:
+ half_width_diff = int(abs(self.canvas_width - frame.width)/2)
+ box = (
+ half_width_diff,
+ 0,
+ frame.width - half_width_diff,
+ frame.height
+ )
+ elif self.canvas_height != frame.height:
+ half_height_diff = int(abs(self.canvas_height - frame.height)/2)
+ box = (
+ 0,
+ half_height_diff,
+ frame.width,
+ frame.height - half_height_diff
+ )
+
+ self.renderer.canvas.paste(frame.crop(box=box))
+
# Check for ANYCLICK to take final entropy image
if self.hw_inputs.check_for_low(keys=HardwareButtonsConstants.KEYS__ANYCLICK):
# Have to manually update last input time since we're not in a wait_for loop
@@ -50,8 +82,6 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
self.camera.stop_video_stream_mode()
with self.renderer.lock:
- self.renderer.canvas.paste(frame)
-
self.renderer.draw.text(
xy=(
int(self.renderer.canvas_width/2),
@@ -70,8 +100,6 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
# If we're still here, it's just another preview frame loop
with self.renderer.lock:
- self.renderer.canvas.paste(frame)
-
self.renderer.draw.text(
xy=(
int(self.renderer.canvas_width/2),
@@ -136,6 +164,7 @@ class ToolsDiceEntropyEntryScreen(KeyboardScreen):
def __post_init__(self):
# TRANSLATOR_NOTE: current roll number vs total rolls (e.g. roll 7 of 50)
self.title = _("Dice Roll {}/{}").format(1, self.return_after_n_chars)
+ self.custom_additional_keys = [Keyboard.KEY_BACKSPACE]
# Specify the keys in the keyboard
self.rows = 3
@@ -183,9 +212,11 @@ class ToolsCalcFinalWordFinalizePromptScreen(ButtonListScreen):
self.is_button_text_centered = True
super().__post_init__()
+ # TRANSLATOR_NOTE: Final word calc. `mnemonic_length` = 12 or 24. `num_bits` = 7 or 3 (bits of entropy in final word).
+ text=_("The {mnemonic_length}th word is built from {num_bits} more entropy bits plus auto-calculated checksum.").format(mnemonic_length=self.mnemonic_length, num_bits=self.num_entropy_bits)
+
self.components.append(TextArea(
- # TRANSLATOR_NOTE: Final word calc. `mnemonic_length` = 12 or 24. `num_bits` = 7 or 3 (bits of entropy in final word).
- text=_("The {mnemonic_length}th word is built from {num_bits} more entropy bits plus auto-calculated checksum.").format(mnemonic_length=self.mnemonic_length, num_bits=self.num_entropy_bits),
+ text=text,
screen_y=self.top_nav.height + int(GUIConstants.COMPONENT_PADDING/2),
))
@@ -197,6 +228,7 @@ class ToolsCoinFlipEntryScreen(KeyboardScreen):
# Override values set by the parent class
# TRANSLATOR_NOTE: current coin-flip number vs total flips (e.g. flip 3 of 4)
self.title = _("Coin Flip {}/{}").format(1, self.return_after_n_chars)
+ self.custom_additional_keys = [Keyboard.KEY_BACKSPACE_2]
# Specify the keys in the keyboard
self.rows = 1
@@ -238,13 +270,17 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
super().__post_init__()
# First what's the total bit display width and where do the checksum bits start?
- bit_font_size = GUIConstants.get_button_font_size() + 2
+ bit_font_size = GUIConstants.get_button_font_size(locale="default") + 2 # bit font size should not vary by locale
font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, bit_font_size)
- (left, top, bit_display_width, bit_font_height) = font.getbbox("0" * 11, anchor="lt")
+ (left, top, bit_display_width, bottom) = font.getbbox("0" * 11, anchor="lt")
(left, top, checksum_x, bottom) = font.getbbox("0" * (11 - len(self.checksum_bits)), anchor="lt")
bit_display_x = int((self.canvas_width - bit_display_width)/2)
checksum_x += bit_display_x
+ y_spacer = GUIConstants.COMPONENT_PADDING
+ if GUIConstants.get_body_font_size() > GUIConstants.get_body_font_size("default"):
+ y_spacer -= 1
+
# Display the user's additional entropy input
if self.selected_final_word:
selection_text = self.selected_final_word
@@ -271,7 +307,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
))
# ...and that entropy's associated 11 bits
- screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
+ screen_y = self.components[-1].screen_y + self.components[-1].height + y_spacer
first_bits_line = TextArea(
text=keeper_selected_bits,
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
@@ -298,18 +334,19 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
is_text_centered=False,
))
- # Show the checksum..
+ # Show the checksum...
self.components.append(TextArea(
# TRANSLATOR_NOTE: A function of "x" to be used for detecting errors in "x"
text=_("Checksum"),
edge_padding=0,
screen_y=first_bits_line.screen_y + first_bits_line.height + 2*GUIConstants.COMPONENT_PADDING,
+ height_ignores_below_baseline=True, # Keep the next line (bits display) snugged up, regardless of text rendering below the baseline
))
# ...and its actual bits. Prepend spacers to keep vertical alignment
checksum_spacer = "_" * (11 - len(self.checksum_bits))
- screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
+ screen_y = self.components[-1].screen_y + self.components[-1].height + y_spacer
# This time we de-emphasize the prepended spacers that are irrelevant
self.components.append(TextArea(
@@ -346,7 +383,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
# Once again show the bits that came from the user's entropy...
num_checksum_bits = len(self.checksum_bits)
user_component = self.selected_final_bits[:11 - num_checksum_bits]
- screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
+ screen_y = self.components[-1].screen_y + self.components[-1].height + y_spacer
self.components.append(TextArea(
text=user_component,
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
@@ -455,8 +492,52 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen):
self.components.append(IconTextLine(
# TRANSLATOR_NOTE: a label for a BIP-380-ish Output Descriptor
label_text=_("Wallet descriptor"),
- value_text=self.wallet_descriptor_display_name, # TODO: English text from embit (e.g. "1 / 2 multisig"); make l10 friendly
+ value_text=self.wallet_descriptor_display_name,
is_text_centered=True,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
))
+
+
+
+@dataclass
+class ToolsAddressExplorerAddressListScreen(ButtonListScreen):
+ start_index: int = 0
+ addresses: list[str] = None
+
+ def __post_init__(self):
+ self.button_font_name = GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME
+ self.button_font_size = GUIConstants.get_button_font_size() + 4
+ self.is_button_text_centered = False
+ self.is_bottom_list = True
+
+ left, top, right, bottom = Fonts.get_font(self.button_font_name, self.button_font_size).getbbox("X")
+ char_width = right - left
+
+ last_addr_index = self.start_index + len(self.addresses) - 1
+ index_digits = len(str(last_addr_index))
+
+ # Calculate how many pixels we have available within each address button,
+ # remembering to account for the index number that will be displayed.
+ # Note: because we haven't called the parent's post_init yet, we don't have a
+ # self.canvas_width set; have to use the Renderer singleton to get it.
+ available_width = Renderer.get_instance().canvas_width - 2*GUIConstants.EDGE_PADDING - 2*GUIConstants.COMPONENT_PADDING - (index_digits + 1)*char_width
+ displayable_chars = int(available_width / char_width) - 3 # ellipsis
+ displayable_half = int(displayable_chars/2)
+
+ self.button_data = []
+ for i, address in enumerate(self.addresses):
+ cur_index = i + self.start_index
+
+ # TODO: Intentionally NOT marking these for translation, but we may need to in
+ # the future.
+ button_label = f"{cur_index}:{address[:displayable_half]}...{address[-1*displayable_half:]}"
+ active_button_label = f"{cur_index}:{address}"
+
+ self.button_data.append(ButtonOption(button_label, active_button_label=active_button_label))
+
+ # TRANSLATOR_NOTE: Insert the number of addrs displayed per screen (e.g. "Next 10")
+ button_label = _("Next {}").format(len(self.addresses))
+ self.button_data.append(ButtonOption(button_label, right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT))
+
+ super().__post_init__()
diff --git a/src/seedsigner/gui/toast.py b/src/seedsigner/gui/toast.py
index 12a282d0..b02fdd5c 100644
--- a/src/seedsigner/gui/toast.py
+++ b/src/seedsigner/gui/toast.py
@@ -45,7 +45,6 @@ class ToastOverlay(BaseComponent):
auto_line_break=True,
width=self.canvas_width - icon_delta_x - 2 * GUIConstants.COMPONENT_PADDING - 2 * self.outline_thickness,
screen_x=icon_delta_x + GUIConstants.COMPONENT_PADDING,
- allow_text_overflow=False,
height_ignores_below_baseline=True,
)
@@ -137,7 +136,6 @@ class BaseToastOverlayManagerThread(BaseThread):
def run(self):
logger.info(f"{self.__class__.__name__}: started")
start = time.time()
- time.sleep(0.2)
while time.time() - start < self.activation_delay:
if self.hw_inputs.has_any_input():
# User has pressed a button, cancel the toast
@@ -202,11 +200,11 @@ class BaseToastOverlayManagerThread(BaseThread):
class RemoveSDCardToastManagerThread(BaseToastOverlayManagerThread):
- def __init__(self, activation_delay: int = 3, duration: int = 1e6):
+ def __init__(self, activation_delay: int = 3, duration: int = 5):
"""
* activation_delay: configurable so the screenshot generator can get the
toast to immediately render.
- * duration: default value is essentially forever. Overrideable for the
+ * duration: default value is 5 seconds. Overrideable for the
screenshot generator.
"""
super().__init__(
diff --git a/src/seedsigner/hardware/buttons.py b/src/seedsigner/hardware/buttons.py
index 2f1a6725..3e230775 100644
--- a/src/seedsigner/hardware/buttons.py
+++ b/src/seedsigner/hardware/buttons.py
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
class HardwareButtons(Singleton):
if GPIO.RPI_INFO['P1_REVISION'] == 3: #This indicates that we have revision 3 GPIO
- logger.info("Detected 40pin GPIO (Rasbperry Pi 2 and above)")
+ logger.info("Detected 40pin GPIO (Raspberry Pi 2 and above)")
KEY_UP_PIN = 31
KEY_DOWN_PIN = 35
KEY_LEFT_PIN = 29
@@ -22,7 +22,7 @@ class HardwareButtons(Singleton):
KEY3_PIN = 36
else:
- logger.info("Assuming 26 Pin GPIO (Raspberry P1 1)")
+ logger.info("Assuming 26 Pin GPIO (Raspberry Pi 1)")
KEY_UP_PIN = 5
KEY_DOWN_PIN = 11
KEY_LEFT_PIN = 3
@@ -64,13 +64,6 @@ class HardwareButtons(Singleton):
return cls._instance
- @classmethod
- def get_instance_no_hardware(cls):
- # This is the only way to access the one and only instance
- if cls._instance is None:
- cls._instance = cls.__new__(cls)
-
-
def wait_for(self, keys=[]) -> int:
"""
Block execution until one of the target keys is pressed.
@@ -89,7 +82,7 @@ class HardwareButtons(Singleton):
return HardwareButtonsConstants.OVERRIDE
cur_time = int(time.time() * 1000)
- if cur_time - self.last_input_time > controller.screensaver_activation_ms and not controller.is_screensaver_running:
+ if cur_time - self.last_input_time > controller.screensaver_activation_ms and controller.is_screensaver_start_allowed:
# Start the screensaver. Will block execution until input detected.
controller.start_screensaver()
diff --git a/src/seedsigner/hardware/camera.py b/src/seedsigner/hardware/camera.py
index ab4f700f..44919fa4 100644
--- a/src/seedsigner/hardware/camera.py
+++ b/src/seedsigner/hardware/camera.py
@@ -1,12 +1,18 @@
import io
+from gettext import gettext as _
from PIL import Image
-from seedsigner.hardware.pivideostream import PiVideoStream
+
from seedsigner.models.settings import Settings, SettingsConstants
from seedsigner.models.singleton import Singleton
+class CameraConnectionError(Exception):
+ pass
+
+
+
class Camera(Singleton):
_video_stream = None
_picamera = None
@@ -22,12 +28,17 @@ class Camera(Singleton):
def start_video_stream_mode(self, resolution=(512, 384), framerate=12, format="bgr"):
+ from picamera import PiCameraError
from seedsigner.hardware.pivideostream import PiVideoStream
if self._video_stream is not None:
self.stop_video_stream_mode()
- self._video_stream = PiVideoStream(resolution=resolution,framerate=framerate, format=format)
- self._video_stream.start()
+ try:
+ self._video_stream = PiVideoStream(resolution=resolution,framerate=framerate, format=format)
+ self._video_stream.start()
+ except PiCameraError:
+ # This error most often occurs because the camera connection is loose
+ raise CameraConnectionError()
def read_video_stream(self, as_image=False):
@@ -49,14 +60,18 @@ class Camera(Singleton):
def start_single_frame_mode(self, resolution=(720, 480)):
- from picamera import PiCamera
+ from picamera import PiCamera, PiCameraError
if self._video_stream is not None:
self.stop_video_stream_mode()
if self._picamera is not None:
self._picamera.close()
- self._picamera = PiCamera(resolution=resolution, framerate=24)
- self._picamera.start_preview()
+ try:
+ self._picamera = PiCamera(resolution=resolution, framerate=24)
+ self._picamera.start_preview()
+ except PiCameraError:
+ # This error most often occurs because the camera connection is loose
+ raise CameraConnectionError()
def capture_frame(self):
diff --git a/src/seedsigner/hardware/ST7789.py b/src/seedsigner/hardware/displays/ST7789.py
similarity index 84%
rename from src/seedsigner/hardware/ST7789.py
rename to src/seedsigner/hardware/displays/ST7789.py
index ebd4952b..8ce5c762 100644
--- a/src/seedsigner/hardware/ST7789.py
+++ b/src/seedsigner/hardware/displays/ST7789.py
@@ -1,169 +1,180 @@
-import spidev
-import RPi.GPIO as GPIO
-import time
-import array
-
-
-
-class ST7789(object):
- """class for ST7789 240*240 1.3inch OLED displays."""
-
- def __init__(self):
- self.width = 240
- self.height = 240
-
- #Initialize DC RST pin
- self._dc = 22
- self._rst = 13
- self._bl = 18
-
- GPIO.setmode(GPIO.BOARD)
- GPIO.setwarnings(False)
- GPIO.setup(self._dc,GPIO.OUT)
- GPIO.setup(self._rst,GPIO.OUT)
- GPIO.setup(self._bl,GPIO.OUT)
- GPIO.output(self._bl, GPIO.HIGH)
-
- #Initialize SPI
- self._spi = spidev.SpiDev(0, 0)
- self._spi.max_speed_hz = 40000000
-
- self.init()
-
-
- """ Write register address and data """
- def command(self, cmd):
- GPIO.output(self._dc, GPIO.LOW)
- self._spi.writebytes([cmd])
-
- def data(self, val):
- GPIO.output(self._dc, GPIO.HIGH)
- self._spi.writebytes([val])
-
- def init(self):
- """Initialize dispaly"""
- self.reset()
-
- self.command(0x36)
- self.data(0x70) #self.data(0x00)
-
- self.command(0x3A)
- self.data(0x05)
-
- self.command(0xB2)
- self.data(0x0C)
- self.data(0x0C)
- self.data(0x00)
- self.data(0x33)
- self.data(0x33)
-
- self.command(0xB7)
- self.data(0x35)
-
- self.command(0xBB)
- self.data(0x19)
-
- self.command(0xC0)
- self.data(0x2C)
-
- self.command(0xC2)
- self.data(0x01)
-
- self.command(0xC3)
- self.data(0x12)
-
- self.command(0xC4)
- self.data(0x20)
-
- self.command(0xC6)
- self.data(0x0F)
-
- self.command(0xD0)
- self.data(0xA4)
- self.data(0xA1)
-
- self.command(0xE0)
- self.data(0xD0)
- self.data(0x04)
- self.data(0x0D)
- self.data(0x11)
- self.data(0x13)
- self.data(0x2B)
- self.data(0x3F)
- self.data(0x54)
- self.data(0x4C)
- self.data(0x18)
- self.data(0x0D)
- self.data(0x0B)
- self.data(0x1F)
- self.data(0x23)
-
- self.command(0xE1)
- self.data(0xD0)
- self.data(0x04)
- self.data(0x0C)
- self.data(0x11)
- self.data(0x13)
- self.data(0x2C)
- self.data(0x3F)
- self.data(0x44)
- self.data(0x51)
- self.data(0x2F)
- self.data(0x1F)
- self.data(0x1F)
- self.data(0x20)
- self.data(0x23)
-
- self.command(0x21)
-
- self.command(0x11)
-
- self.command(0x29)
-
- def reset(self):
- """Reset the display"""
- GPIO.output(self._rst,GPIO.HIGH)
- time.sleep(0.01)
- GPIO.output(self._rst,GPIO.LOW)
- time.sleep(0.01)
- GPIO.output(self._rst,GPIO.HIGH)
- time.sleep(0.01)
-
- def SetWindows(self, Xstart, Ystart, Xend, Yend):
- #set the X coordinates
- self.command(0x2A)
- self.data(0x00) #Set the horizontal starting point to the high octet
- self.data(Xstart & 0xff) #Set the horizontal starting point to the low octet
- self.data(0x00) #Set the horizontal end to the high octet
- self.data((Xend - 1) & 0xff) #Set the horizontal end to the low octet
-
- #set the Y coordinates
- self.command(0x2B)
- self.data(0x00)
- self.data((Ystart & 0xff))
- self.data(0x00)
- self.data((Yend - 1) & 0xff )
-
- self.command(0x2C)
-
- def ShowImage(self,Image,Xstart,Ystart):
- """Set buffer to value of Python Imaging Library image."""
- """Write display buffer to physical display"""
- imwidth, imheight = Image.size
- if imwidth != self.width or imheight != self.height:
- raise ValueError('Image must be same dimensions as display \
- ({0}x{1}).' .format(self.width, self.height))
- # convert 24-bit RGB-8:8:8 to gBRG-3:5:5:3; then per-pixel byteswap to 16-bit RGB-5:6:5
- arr = array.array("H", Image.convert("BGR;16").tobytes())
- arr.byteswap()
- pix = arr.tobytes()
- self.SetWindows ( 0, 0, self.width, self.height)
- GPIO.output(self._dc,GPIO.HIGH)
- self._spi.writebytes2(pix)
-
- def clear(self):
- """Clear contents of image buffer"""
- _buffer = [0xff]*(self.width * self.height * 2)
- self.SetWindows ( 0, 0, self.width, self.height)
- GPIO.output(self._dc,GPIO.HIGH)
- self._spi.writebytes2(_buffer)
+import spidev
+import RPi.GPIO as GPIO
+import time
+import array
+from dataclasses import dataclass
+
+from seedsigner.hardware.displays.display_driver import BaseDisplayDriver
+
+
+
+@dataclass
+class ST7789(BaseDisplayDriver):
+ """
+ The original SeedSigner display driver.
+
+ Note that self._width and self._height are provided by the parent DisplayDriver class
+ and are set during instantiation via the DisplayDriverFactory.
+
+ class for ST7789 240*240 1.3inch OLED displays.
+ """
+ def __post_init__(self):
+ #Initialize DC RST pin
+ self._dc = 22
+ self._rst = 13
+ self._bl = 18
+
+ GPIO.setmode(GPIO.BOARD)
+ GPIO.setwarnings(False)
+ GPIO.setup(self._dc,GPIO.OUT)
+ GPIO.setup(self._rst,GPIO.OUT)
+ GPIO.setup(self._bl,GPIO.OUT)
+ GPIO.output(self._bl, GPIO.HIGH)
+
+ #Initialize SPI
+ self._spi = spidev.SpiDev(0, 0)
+ self._spi.max_speed_hz = 40000000
+
+ self.init()
+
+
+ """ Write register address and data """
+ def command(self, cmd):
+ GPIO.output(self._dc, GPIO.LOW)
+ self._spi.writebytes([cmd])
+
+ def data(self, val):
+ GPIO.output(self._dc, GPIO.HIGH)
+ self._spi.writebytes([val])
+
+ def init(self):
+ """Initialize display"""
+ self.reset()
+
+ self.command(0x36)
+ self.data(0x70) #self.data(0x00)
+
+ self.command(0x3A)
+ self.data(0x05)
+
+ self.command(0xB2)
+ self.data(0x0C)
+ self.data(0x0C)
+ self.data(0x00)
+ self.data(0x33)
+ self.data(0x33)
+
+ self.command(0xB7)
+ self.data(0x35)
+
+ self.command(0xBB)
+ self.data(0x19)
+
+ self.command(0xC0)
+ self.data(0x2C)
+
+ self.command(0xC2)
+ self.data(0x01)
+
+ self.command(0xC3)
+ self.data(0x12)
+
+ self.command(0xC4)
+ self.data(0x20)
+
+ self.command(0xC6)
+ self.data(0x0F)
+
+ self.command(0xD0)
+ self.data(0xA4)
+ self.data(0xA1)
+
+ self.command(0xE0)
+ self.data(0xD0)
+ self.data(0x04)
+ self.data(0x0D)
+ self.data(0x11)
+ self.data(0x13)
+ self.data(0x2B)
+ self.data(0x3F)
+ self.data(0x54)
+ self.data(0x4C)
+ self.data(0x18)
+ self.data(0x0D)
+ self.data(0x0B)
+ self.data(0x1F)
+ self.data(0x23)
+
+ self.command(0xE1)
+ self.data(0xD0)
+ self.data(0x04)
+ self.data(0x0C)
+ self.data(0x11)
+ self.data(0x13)
+ self.data(0x2C)
+ self.data(0x3F)
+ self.data(0x44)
+ self.data(0x51)
+ self.data(0x2F)
+ self.data(0x1F)
+ self.data(0x1F)
+ self.data(0x20)
+ self.data(0x23)
+
+ self.command(0x21) # inversion ON; 0x20 = inversion OFF
+
+ self.command(0x11)
+
+ self.command(0x29)
+
+ def reset(self):
+ """Reset the display"""
+ GPIO.output(self._rst,GPIO.HIGH)
+ time.sleep(0.01)
+ GPIO.output(self._rst,GPIO.LOW)
+ time.sleep(0.01)
+ GPIO.output(self._rst,GPIO.HIGH)
+ time.sleep(0.01)
+
+ def SetWindows(self, Xstart, Ystart, Xend, Yend):
+ #set the X coordinates
+ self.command(0x2A)
+ self.data(0x00) #Set the horizontal starting point to the high octet
+ self.data(Xstart & 0xff) #Set the horizontal starting point to the low octet
+ self.data(0x00) #Set the horizontal end to the high octet
+ self.data((Xend - 1) & 0xff) #Set the horizontal end to the low octet
+
+ #set the Y coordinates
+ self.command(0x2B)
+ self.data(0x00)
+ self.data((Ystart & 0xff))
+ self.data(0x00)
+ self.data((Yend - 1) & 0xff )
+
+ self.command(0x2C)
+
+ def show_image(self,Image,Xstart,Ystart):
+ """Set buffer to value of Python Imaging Library image."""
+ """Write display buffer to physical display"""
+ imwidth, imheight = Image.size
+ if imwidth != self.width or imheight != self.height:
+ raise ValueError('Image must be same dimensions as display \
+ ({0}x{1}).' .format(self.width, self.height))
+ # convert 24-bit RGB-8:8:8 to gBRG-3:5:5:3; then per-pixel byteswap to 16-bit RGB-5:6:5
+ arr = array.array("H", Image.convert("BGR;16").tobytes())
+ arr.byteswap()
+ pix = arr.tobytes()
+ self.SetWindows ( 0, 0, self.width, self.height)
+ GPIO.output(self._dc,GPIO.HIGH)
+ self._spi.writebytes2(pix)
+
+ def clear(self):
+ """Clear contents of image buffer"""
+ _buffer = [0xff]*(self.width * self.height * 2)
+ self.SetWindows ( 0, 0, self.width, self.height)
+ GPIO.output(self._dc,GPIO.HIGH)
+ self._spi.writebytes2(_buffer)
+
+ def invert(self, enabled: bool = True):
+ """Invert how the display interprets colors"""
+ self.command(0x21 if enabled else 0x20)
diff --git a/src/seedsigner/hardware/displays/__init__.py b/src/seedsigner/hardware/displays/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/seedsigner/hardware/displays/display_driver.py b/src/seedsigner/hardware/displays/display_driver.py
new file mode 100644
index 00000000..0870e6e1
--- /dev/null
+++ b/src/seedsigner/hardware/displays/display_driver.py
@@ -0,0 +1,92 @@
+from dataclasses import dataclass
+
+
+DISPLAY_TYPE__ST7789 = "st7789"
+DISPLAY_TYPE__ILI9341 = "ili9341"
+DISPLAY_TYPE__ILI9486 = "ili9486"
+
+ALL_DISPLAY_TYPES = [DISPLAY_TYPE__ST7789, DISPLAY_TYPE__ILI9341, DISPLAY_TYPE__ILI9486]
+
+
+
+@dataclass
+class BaseDisplayDriver:
+ _width: int
+ _height: int
+
+ def __str__(self):
+ return f"DisplayDriver(display_type={getattr(self, 'display_type', None)}, width={self.width}, height={self.height})"
+
+
+ @property
+ def width(self):
+ return self._width
+
+
+ @property
+ def height(self):
+ return self._height
+
+
+ def invert(self, enabled: bool = True):
+ """
+ Invert how the display interprets colors.
+ Implementation in child classes is optional.
+ """
+ pass
+
+
+ def show_image(self, image, x_start: int = 0, y_start: int = 0):
+ """
+ The main rendering call to the display driver.
+ Must be implemented in child classes.
+ """
+ raise Exception("show_image() must be implemented in child classes")
+
+
+ def cleanup(self):
+ """
+ Cleanup resources used by the display driver.
+ Implementation in child classes is optional.
+ """
+ pass
+
+
+
+class DisplayDriverFactory:
+ """
+ Manages all logic related to instantiating display drivers based on type and resolution.
+
+ Imports for specific display drivers are done within this class to avoid circular imports.
+ """
+
+ @classmethod
+ def instantiate_display_driver(cls, display_type: str = DISPLAY_TYPE__ST7789, width: int = None, height: int = None) -> BaseDisplayDriver:
+ if display_type not in ALL_DISPLAY_TYPES:
+ raise ValueError(f"Invalid display type: {display_type}")
+
+ if display_type == DISPLAY_TYPE__ST7789:
+ if width not in [240, 320] or height != 240:
+ raise ValueError("ST7789 display only supports 240x240 or 320x240 resolutions")
+
+ if width == 240:
+ # TODO: For now the original ST7789 driver has to be used for 240x240.
+ # The mpy version below renders incorrectly (almost like each row of pixels
+ # is one pixel short, so the entire screen exhibits a diagonal skew).
+ from seedsigner.hardware.displays.ST7789 import ST7789 as original_ST7789
+ return original_ST7789(_width=width, _height=height)
+
+ elif width == 320:
+ from seedsigner.hardware.displays.st7789_mpy import ST7789 as mpy_ST7789
+ # Have to swap width and height; screen is natively 240x320
+ return mpy_ST7789(_width=height, _height=width)
+
+ elif display_type == DISPLAY_TYPE__ILI9341:
+ from seedsigner.hardware.displays.ili9341 import ILI9341
+ display = ILI9341(_width=width, _height=height)
+ display.begin()
+ return display
+
+ elif display_type == DISPLAY_TYPE__ILI9486:
+ # TODO: improve performance of ili9486 driver
+ raise Exception("ILI9486 display not implemented yet")
diff --git a/src/seedsigner/hardware/displays/ili9341.py b/src/seedsigner/hardware/displays/ili9341.py
new file mode 100644
index 00000000..23d1bb51
--- /dev/null
+++ b/src/seedsigner/hardware/displays/ili9341.py
@@ -0,0 +1,369 @@
+# Copyright (c) 2014 Adafruit Industries
+# Author: Tony DiCola
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+"""
+Tested with a 320x240 IPS display (https://a.co/d/2Q9wDLo)
+* Framerate is excellent (~10fps)
+* Requires `invert()` and 90° rotation
+* Exhibits noticeable residual ghosting
+"""
+import numbers
+import time
+# import numpy as np
+import array
+
+from dataclasses import dataclass
+
+from PIL import Image
+from PIL import ImageDraw
+
+import RPi.GPIO as GPIO
+from spidev import SpiDev
+
+from seedsigner.hardware.displays.display_driver import BaseDisplayDriver
+
+
+# Constants for interacting with display registers.
+ILI9341_TFTWIDTH = 240
+ILI9341_TFTHEIGHT = 320
+
+ILI9341_NOP = 0x00
+ILI9341_SWRESET = 0x01
+ILI9341_RDDID = 0x04
+ILI9341_RDDST = 0x09
+
+ILI9341_SLPIN = 0x10
+ILI9341_SLPOUT = 0x11
+ILI9341_PTLON = 0x12
+ILI9341_NORON = 0x13
+
+ILI9341_RDMODE = 0x0A
+ILI9341_RDMADCTL = 0x0B
+ILI9341_RDPIXFMT = 0x0C
+ILI9341_RDIMGFMT = 0x0A
+ILI9341_RDSELFDIAG = 0x0F
+
+ILI9341_INVOFF = 0x20
+ILI9341_INVON = 0x21
+ILI9341_GAMMASET = 0x26
+ILI9341_DISPOFF = 0x28
+ILI9341_DISPON = 0x29
+
+ILI9341_CASET = 0x2A
+ILI9341_PASET = 0x2B
+ILI9341_RAMWR = 0x2C
+ILI9341_RAMRD = 0x2E
+
+ILI9341_PTLAR = 0x30
+ILI9341_MADCTL = 0x36
+ILI9341_PIXFMT = 0x3A
+
+ILI9341_FRMCTR1 = 0xB1
+ILI9341_FRMCTR2 = 0xB2
+ILI9341_FRMCTR3 = 0xB3
+ILI9341_INVCTR = 0xB4
+ILI9341_DFUNCTR = 0xB6
+
+ILI9341_PWCTR1 = 0xC0
+ILI9341_PWCTR2 = 0xC1
+ILI9341_PWCTR3 = 0xC2
+ILI9341_PWCTR4 = 0xC3
+ILI9341_PWCTR5 = 0xC4
+ILI9341_VMCTR1 = 0xC5
+ILI9341_VMCTR2 = 0xC7
+
+ILI9341_RDID1 = 0xDA
+ILI9341_RDID2 = 0xDB
+ILI9341_RDID3 = 0xDC
+ILI9341_RDID4 = 0xDD
+
+ILI9341_GMCTRP1 = 0xE0
+ILI9341_GMCTRN1 = 0xE1
+
+ILI9341_PWCTR6 = 0xFC
+
+ILI9341_BLACK = 0x0000
+ILI9341_BLUE = 0x001F
+ILI9341_RED = 0xF800
+ILI9341_GREEN = 0x07E0
+ILI9341_CYAN = 0x07FF
+ILI9341_MAGENTA = 0xF81F
+ILI9341_YELLOW = 0xFFE0
+ILI9341_WHITE = 0xFFFF
+
+
+def color565(r, g, b):
+ """Convert red, green, blue components to a 16-bit 565 RGB value. Components
+ should be values 0 to 255.
+ """
+ return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
+
+def image_to_data(image):
+ """Generator function to convert a PIL image to 16-bit 565 RGB bytes."""
+ #NumPy is much faster at doing this. NumPy code provided by:
+ #Keith (https://www.blogger.com/profile/02555547344016007163)
+ # pb = np.array(image.convert('RGB')).astype('uint16')
+ # color = ((pb[:,:,0] & 0xF8) << 8) | ((pb[:,:,1] & 0xFC) << 3) | (pb[:,:,2] >> 3)
+ # return np.dstack(((color >> 8) & 0xFF, color & 0xFF)).flatten().tolist()
+
+ # convert 24-bit RGB-8:8:8 to gBRG-3:5:5:3 ("BGR;16"):
+ # 3 highest bits of green + 5 highest bits of blue in the first byte and
+ # 5 highest bits of red + the next 3 highest bits of green (not yet expressed) in the second byte.
+ # Then per-pixel byteswap to 16-bit RGB-5:6:5
+ # This approach was measured to be ~3.4x faster than the numpy code above.
+ arr = array.array("H", image.convert("BGR;16").tobytes())
+ arr.byteswap()
+ return arr.tobytes()
+
+
+@dataclass
+class ILI9341(BaseDisplayDriver):
+ """Representation of an ILI9341 TFT LCD."""
+
+ def __post_init__(self):
+ dc=22
+ rst=13
+ led=12
+ rotation=90
+ spi = SpiDev(0, 0)
+ # spi.mode = 0b10 # [CPOL|CPHA] -> polarity 1, phase 0
+ spi.max_speed_hz = 64_000_000
+
+ self._dc = dc
+ self._rst = rst
+ self._spi = spi
+ self.rotation = rotation
+ self.inverted = False
+ # if self._gpio is None:
+ # self._gpio = GPIO.get_platform_gpio()
+ # Set DC as output.
+
+ GPIO.setmode(GPIO.BOARD) # Use physical pin nums, not gpio labels
+ GPIO.setwarnings(False)
+ GPIO.setup(self._dc, GPIO.OUT)
+ GPIO.output(self._dc, GPIO.HIGH)
+ GPIO.setup(led, GPIO.OUT)
+ GPIO.output(led, GPIO.HIGH)
+ if self._rst is not None:
+ GPIO.setup(self._rst, GPIO.OUT)
+ GPIO.output(self._rst, GPIO.HIGH)
+
+ # Create an image buffer.
+ self.buffer = Image.new('RGB', (self.width, self.height))
+
+
+ def send(self, data, is_data=True, chunk_size=4096):
+ """Write a byte or array of bytes to the display. Is_data parameter
+ controls if byte should be interpreted as display data (True) or command
+ data (False). Chunk_size is an optional size of bytes to write in a
+ single SPI transaction, with a default of 4096.
+ """
+ # Set DC low for command, high for data.
+ GPIO.output(self._dc, is_data)
+ # Convert scalar argument to list so either can be passed as parameter.
+ if isinstance(data, numbers.Number):
+ data = [data & 0xFF]
+ # Write data a chunk at a time.
+ # for start in range(0, len(data), chunk_size):
+ # end = min(start+chunk_size, len(data))
+ # self._spi.writebytes2(data[start:end])
+
+ self._spi.writebytes2(data)
+
+ def command(self, data):
+ """Write a byte or array of bytes to the display as command data."""
+ self.send(data, False)
+
+ def data(self, data):
+ """Write a byte or array of bytes to the display as display data."""
+ self.send(data, True)
+
+ def reset(self):
+ """Reset the display, if reset pin is connected."""
+ if self._rst is not None:
+ GPIO.output(self._rst, GPIO.HIGH)
+ time.sleep(0.005)
+ GPIO.output(self._rst, GPIO.LOW)
+ time.sleep(0.02)
+ GPIO.output(self._rst, GPIO.HIGH)
+ time.sleep(0.150)
+
+ def _init(self):
+ # Initialize the display. Broken out as a separate function so it can
+ # be overridden by other displays in the future.
+ self.command(0xEF)
+ self.data(0x03)
+ self.data(0x80)
+ self.data(0x02)
+ self.command(0xCF)
+ self.data(0x00)
+ self.data(0XC1)
+ self.data(0X30)
+ self.command(0xED)
+ self.data(0x64)
+ self.data(0x03)
+ self.data(0X12)
+ self.data(0X81)
+ self.command(0xE8)
+ self.data(0x85)
+ self.data(0x00)
+ self.data(0x78)
+ self.command(0xCB)
+ self.data(0x39)
+ self.data(0x2C)
+ self.data(0x00)
+ self.data(0x34)
+ self.data(0x02)
+ self.command(0xF7)
+ self.data(0x20)
+ self.command(0xEA)
+ self.data(0x00)
+ self.data(0x00)
+ self.command(ILI9341_PWCTR1) # Power control
+ self.data(0x23) # VRH[5:0]
+ self.command(ILI9341_PWCTR2) # Power control
+ self.data(0x10) # SAP[2:0];BT[3:0]
+ self.command(ILI9341_VMCTR1) # VCM control
+ self.data(0x3e)
+ self.data(0x28)
+ self.command(ILI9341_VMCTR2) # VCM control2
+ self.data(0x86) # --
+ self.command(ILI9341_MADCTL) # Memory Access Control
+ self.data(0x48)
+ self.command(ILI9341_PIXFMT)
+ self.data(0x55)
+ self.command(ILI9341_FRMCTR1)
+ self.data(0x00)
+ self.data(0x18)
+ self.command(ILI9341_DFUNCTR) # Display Function Control
+ self.data(0x08)
+ self.data(0x82)
+ self.data(0x27)
+ self.command(0xF2) # 3Gamma Function Disable
+ self.data(0x00)
+ self.command(ILI9341_GAMMASET) # Gamma curve selected
+ self.data(0x01)
+ self.command(ILI9341_GMCTRP1) # Set Gamma
+ self.data(0x0F)
+ self.data(0x31)
+ self.data(0x2B)
+ self.data(0x0C)
+ self.data(0x0E)
+ self.data(0x08)
+ self.data(0x4E)
+ self.data(0xF1)
+ self.data(0x37)
+ self.data(0x07)
+ self.data(0x10)
+ self.data(0x03)
+ self.data(0x0E)
+ self.data(0x09)
+ self.data(0x00)
+ self.command(ILI9341_GMCTRN1) # Set Gamma
+ self.data(0x00)
+ self.data(0x0E)
+ self.data(0x14)
+ self.data(0x03)
+ self.data(0x11)
+ self.data(0x07)
+ self.data(0x31)
+ self.data(0xC1)
+ self.data(0x48)
+ self.data(0x08)
+ self.data(0x0F)
+ self.data(0x0C)
+ self.data(0x31)
+ self.data(0x36)
+ self.data(0x0F)
+ self.command(ILI9341_SLPOUT) # Exit Sleep
+ time.sleep(0.120)
+ self.command(ILI9341_DISPON) # Display on
+
+ def begin(self):
+ """Initialize the display. Should be called once before other calls that
+ interact with the display are called.
+ """
+ self.reset()
+ self._init()
+
+ def invert(self, enabled: bool = True):
+ """Sets display inversion to the specified state. If not provided, state
+ is True, which inverts the display. If state is False, the display turns
+ back into normal mode."""
+ if enabled:
+ self.command(ILI9341_INVON)
+ else:
+ self.command(ILI9341_INVOFF)
+ self.inverted = enabled
+ return self
+
+ def set_window(self, x0=0, y0=0, x1=None, y1=None):
+ """Set the pixel address window for proceeding drawing commands. x0 and
+ x1 should define the minimum and maximum x pixel bounds. y0 and y1
+ should define the minimum and maximum y pixel bound. If no parameters
+ are specified the default will be to update the entire display from 0,0
+ to 239,319.
+ """
+ if x1 is None:
+ x1 = self.width-1
+ if y1 is None:
+ y1 = self.height-1
+ self.command(ILI9341_CASET) # Column addr set
+ self.data(x0 >> 8)
+ self.data(x0) # XSTART
+ self.data(x1 >> 8)
+ self.data(x1) # XEND
+ self.command(ILI9341_PASET) # Row addr set
+ self.data(y0 >> 8)
+ self.data(y0) # YSTART
+ self.data(y1 >> 8)
+ self.data(y1) # YEND
+ self.command(ILI9341_RAMWR) # write to RAM
+
+ def show_image(self, image=None, x_start: int = 0, y_start: int = 0):
+ """Write the display buffer or provided image to the hardware. If no
+ image parameter is provided the display buffer will be written to the
+ hardware. If an image is provided, it should be RGB format and the
+ same dimensions as the display hardware.
+ """
+ # By default write the internal buffer to the display.
+ if image is None:
+ image = self.buffer
+
+ output_image = image.rotate(self.rotation, expand=True)
+ self.set_window(x_start, y_start, x_start + output_image.width - 1, y_start + output_image.height - 1)
+
+ # Convert image to array of 16bit 565 RGB data bytes.
+ # Unfortunate that this copy has to occur, but the SPI byte writing
+ # function needs to take an array of bytes and PIL doesn't natively
+ # store images in 16-bit 565 RGB format.
+ pixelbytes = image_to_data(output_image)
+
+ # Write data to hardware.
+ self.data(pixelbytes)
+
+ def clear(self, color=(0,0,0)):
+ """Clear the image buffer to the specified RGB color (default black)."""
+ width, height = self.buffer.size
+ self.buffer.putdata([color]*(width*height))
+
+ def draw(self):
+ """Return a PIL ImageDraw instance for 2D drawing on the image buffer."""
+ return ImageDraw.Draw(self.buffer)
\ No newline at end of file
diff --git a/src/seedsigner/hardware/displays/st7789_mpy.py b/src/seedsigner/hardware/displays/st7789_mpy.py
new file mode 100644
index 00000000..b69999c4
--- /dev/null
+++ b/src/seedsigner/hardware/displays/st7789_mpy.py
@@ -0,0 +1,1033 @@
+"""
+MIT License
+
+Copyright (c) 2020-2023 Russ Hughes
+
+Copyright (c) 2019 Ivan Belokobylskiy
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+The driver is based on devbis' st7789py_mpy module from
+https://github.com/devbis/st7789py_mpy.
+
+This driver supports:
+
+- 320x240, 240x240, 135x240 and 128x128 pixel displays
+- Display rotation
+- RGB and BGR color orders
+- Hardware based scrolling
+- Drawing text using 8 and 16 bit wide bitmap fonts with heights that are
+ multiples of 8. Included are 12 bitmap fonts derived from classic pc
+ BIOS text mode fonts.
+- Drawing text using converted TrueType fonts.
+- Drawing converted bitmaps
+- Named color constants
+
+ - BLACK
+ - BLUE
+ - RED
+ - GREEN
+ - CYAN
+ - MAGENTA
+ - YELLOW
+ - WHITE
+
+"""
+
+import array
+import spidev
+import RPi.GPIO as GPIO
+
+from dataclasses import dataclass
+from math import sin, cos
+
+from seedsigner.hardware.displays.display_driver import BaseDisplayDriver
+
+#
+# This allows sphinx to build the docs
+#
+
+try:
+ from time import sleep_ms
+except ImportError:
+ sleep_ms = lambda ms: None
+ uint = int
+ const = lambda x: x
+
+ class micropython:
+ @staticmethod
+ def viper(func):
+ return func
+
+ @staticmethod
+ def native(func):
+ return func
+
+
+#
+# If you don't need to build the docs, you can remove all of the lines between
+# here and the comment above except for the "from time import sleep_ms" line.
+#
+
+import struct
+
+# ST7789 commands
+_ST7789_SWRESET = b"\x01"
+_ST7789_SLPIN = b"\x10"
+_ST7789_SLPOUT = b"\x11"
+_ST7789_NORON = b"\x13"
+_ST7789_INVOFF = b"\x20"
+_ST7789_INVON = b"\x21"
+_ST7789_DISPOFF = b"\x28"
+_ST7789_DISPON = b"\x29"
+_ST7789_CASET = b"\x2a"
+_ST7789_RASET = b"\x2b"
+_ST7789_RAMWR = b"\x2c"
+_ST7789_VSCRDEF = b"\x33"
+_ST7789_COLMOD = b"\x3a"
+_ST7789_MADCTL = b"\x36"
+_ST7789_VSCSAD = b"\x37"
+_ST7789_RAMCTL = b"\xb0"
+
+# MADCTL bits
+_ST7789_MADCTL_MY = const(0x80)
+_ST7789_MADCTL_MX = const(0x40)
+_ST7789_MADCTL_MV = const(0x20)
+_ST7789_MADCTL_ML = const(0x10)
+_ST7789_MADCTL_BGR = const(0x08)
+_ST7789_MADCTL_MH = const(0x04)
+_ST7789_MADCTL_RGB = const(0x00)
+
+RGB = 0x00
+BGR = 0x08
+
+# Color modes
+_COLOR_MODE_65K = const(0x50)
+_COLOR_MODE_262K = const(0x60)
+_COLOR_MODE_12BIT = const(0x03)
+_COLOR_MODE_16BIT = const(0x05)
+_COLOR_MODE_18BIT = const(0x06)
+_COLOR_MODE_16M = const(0x07)
+
+# Color definitions
+BLACK = const(0x0000)
+BLUE = const(0x001F)
+RED = const(0xF800)
+GREEN = const(0x07E0)
+CYAN = const(0x07FF)
+MAGENTA = const(0xF81F)
+YELLOW = const(0xFFE0)
+WHITE = const(0xFFFF)
+
+_ENCODE_PIXEL = const(">H")
+_ENCODE_PIXEL_SWAPPED = const("HH")
+_ENCODE_POS_16 = const("> 3
+
+
+
+@dataclass
+class ST7789(BaseDisplayDriver):
+ """
+ ST7789 driver class
+
+ Args:
+ spi (spi): spi object **Required**
+ width (int): display width **Required**
+ height (int): display height **Required**
+ reset (pin): reset pin
+ dc (pin): dc pin **Required**
+ cs (pin): cs pin
+ backlight(pin): backlight pin
+ rotation (int):
+
+ - 0-Portrait
+ - 1-Landscape
+ - 2-Inverted Portrait
+ - 3-Inverted Landscape
+
+ color_order (int):
+
+ - RGB: Red, Green Blue, default
+ - BGR: Blue, Green, Red
+
+ custom_init (tuple): custom initialization commands
+
+ - ((b'command', b'data', delay_ms), ...)
+
+ custom_rotations (tuple): custom rotation definitions
+
+ - ((width, height, xstart, ystart, madctl, needs_swap), ...)
+
+ """
+
+ def __post_init__(self):
+ reset=13
+ dc=22
+ cs=None
+ backlight=18
+ rotation=1
+ color_order=BGR
+ custom_init=None
+ custom_rotations=None
+
+ GPIO.setmode(GPIO.BOARD)
+ GPIO.setwarnings(False)
+ GPIO.setup(dc,GPIO.OUT)
+ GPIO.setup(reset,GPIO.OUT)
+ GPIO.setup(backlight,GPIO.OUT)
+
+ #Initialize SPI
+ spi = spidev.SpiDev(0, 0)
+ spi.max_speed_hz = 40000000
+
+ """
+ Initialize display.
+ """
+ self.rotations = custom_rotations or self._find_rotations(self.width, self.height)
+ if not self.rotations:
+ supported_displays = ", ".join(
+ [f"{display[0]}x{display[1]}" for display in _SUPPORTED_DISPLAYS]
+ )
+ raise ValueError(
+ f"Unsupported {self.width}x{self.height} display. Supported displays: {supported_displays}"
+ )
+
+ if dc is None:
+ raise ValueError("dc pin is required.")
+
+ self.physical_width = self.width
+ self.physical_height = self.height
+ self.xstart = 0
+ self.ystart = 0
+ self.spi = spi
+ self.reset = reset
+ self.dc = dc
+ self.cs = cs
+ self.backlight = backlight
+ self._rotation = rotation % 4
+ self.color_order = color_order
+ self.init_cmds = custom_init or _ST7789_INIT_CMDS
+ self.hard_reset()
+ # yes, twice, once is not always enough
+ self.init(self.init_cmds)
+ self.init(self.init_cmds)
+ self.rotation(self._rotation)
+ self.needs_swap = False
+ self.fill(0x0)
+
+ if backlight is not None:
+ GPIO.output(backlight, GPIO.HIGH)
+ # backlight.value(1)
+
+ @staticmethod
+ def _find_rotations(width, height):
+ for display in _SUPPORTED_DISPLAYS:
+ if display[0] == width and display[1] == height:
+ return display[2]
+ return None
+
+ def init(self, commands):
+ """
+ Initialize display.
+ """
+ for command, data, delay in commands:
+ self._write(command, data)
+ sleep_ms(delay)
+
+ def invert(self, enabled: bool = True):
+ self.inversion_mode(enabled)
+
+ def show_image(self, image, x_start: int = 0, y_start: int = 0):
+ """Set buffer to value of Python Imaging Library image."""
+ """Write display buffer to physical display"""
+
+ # image = image.rotate(90, expand=True)
+
+ imwidth, imheight = image.size
+ if imwidth != self.width or imheight != self.height:
+ raise ValueError('Image must be same dimensions as display \
+ ({0}x{1}).' .format(self.width, self.height))
+
+ # convert 24-bit RGB-8:8:8 to gBRG-3:5:5:3; then per-pixel byteswap to 16-bit RGB-5:6:5
+ arr = array.array("H", image.convert("BGR;16").tobytes())
+ arr.byteswap()
+ pix = arr.tobytes()
+
+ self._set_window(x_start, y_start, self.width, self.height)
+ GPIO.output(self.dc,GPIO.HIGH)
+ self._write(data=pix)
+
+ def _write(self, command=None, data=None):
+ """SPI write to the device: commands and data."""
+ if self.cs:
+ GPIO.output(self.cs, GPIO.LOW)
+ if command is not None:
+ GPIO.output(self.dc, GPIO.LOW)
+ self.spi.writebytes2(command)
+ if data is not None:
+ GPIO.output(self.dc,GPIO.HIGH)
+ self.spi.writebytes2(data)
+ if self.cs:
+ GPIO.output(self.cs,GPIO.HIGH)
+
+ def hard_reset(self):
+ """
+ Hard reset display.
+ """
+ if self.cs:
+ GPIO.output(self.cs, GPIO.LOW)
+ if self.reset:
+ GPIO.output(self.reset, GPIO.HIGH)
+ sleep_ms(10)
+ if self.reset:
+ GPIO.output(self.reset, GPIO.LOW)
+ sleep_ms(10)
+ if self.reset:
+ GPIO.output(self.reset, GPIO.HIGH)
+ sleep_ms(120)
+ if self.cs:
+ GPIO.output(self.cs, GPIO.HIGH)
+
+ def soft_reset(self):
+ """
+ Soft reset display.
+ """
+ self._write(_ST7789_SWRESET)
+ sleep_ms(150)
+
+ def sleep_mode(self, value):
+ """
+ Enable or disable display sleep mode.
+
+ Args:
+ value (bool): if True enable sleep mode. if False disable sleep
+ mode
+ """
+ if value:
+ self._write(_ST7789_SLPIN)
+ else:
+ self._write(_ST7789_SLPOUT)
+
+ def inversion_mode(self, value):
+ """
+ Enable or disable display inversion mode.
+
+ Args:
+ value (bool): if True enable inversion mode. if False disable
+ inversion mode
+ """
+ if value:
+ self._write(_ST7789_INVON)
+ else:
+ self._write(_ST7789_INVOFF)
+
+ def rotation(self, rotation):
+ """
+ Set display rotation.
+
+ Args:
+ rotation (int):
+ - 0-Portrait
+ - 1-Landscape
+ - 2-Inverted Portrait
+ - 3-Inverted Landscape
+
+ custom_rotations can have any number of rotations
+ """
+ rotation %= len(self.rotations)
+ self._rotation = rotation
+ (
+ madctl,
+ self._width, # have to use the settable internal vars
+ self._height, # have to use the settable internal vars
+ self.xstart,
+ self.ystart,
+ self.needs_swap,
+ ) = self.rotations[rotation]
+
+ if self.color_order == BGR:
+ madctl |= _ST7789_MADCTL_BGR
+ else:
+ madctl &= ~_ST7789_MADCTL_BGR
+
+ self._write(_ST7789_MADCTL, bytes([madctl]))
+
+ def _set_window(self, x0, y0, x1, y1):
+ """
+ Set window to column and row address.
+
+ Args:
+ x0 (int): column start address
+ y0 (int): row start address
+ x1 (int): column end address
+ y1 (int): row end address
+ """
+ if x0 <= x1 <= self.width and y0 <= y1 <= self.height:
+ self._write(
+ _ST7789_CASET,
+ struct.pack(_ENCODE_POS, x0 + self.xstart, x1 + self.xstart),
+ )
+ self._write(
+ _ST7789_RASET,
+ struct.pack(_ENCODE_POS, y0 + self.ystart, y1 + self.ystart),
+ )
+ self._write(_ST7789_RAMWR)
+
+ def vline(self, x, y, length, color):
+ """
+ Draw vertical line at the given location and color.
+
+ Args:
+ x (int): x coordinate
+ Y (int): y coordinate
+ length (int): length of line
+ color (int): 565 encoded color
+ """
+ self.fill_rect(x, y, 1, length, color)
+
+ def hline(self, x, y, length, color):
+ """
+ Draw horizontal line at the given location and color.
+
+ Args:
+ x (int): x coordinate
+ Y (int): y coordinate
+ length (int): length of line
+ color (int): 565 encoded color
+ """
+ self.fill_rect(x, y, length, 1, color)
+
+ def pixel(self, x, y, color):
+ """
+ Draw a pixel at the given location and color.
+
+ Args:
+ x (int): x coordinate
+ Y (int): y coordinate
+ color (int): 565 encoded color
+ """
+ self._set_window(x, y, x, y)
+ self._write(
+ None,
+ struct.pack(
+ _ENCODE_PIXEL_SWAPPED if self.needs_swap else _ENCODE_PIXEL, color
+ ),
+ )
+
+ def blit_buffer(self, buffer, x, y, width, height):
+ """
+ Copy buffer to display at the given location.
+
+ Args:
+ buffer (bytes): Data to copy to display
+ x (int): Top left corner x coordinate
+ Y (int): Top left corner y coordinate
+ width (int): Width
+ height (int): Height
+ """
+ self._set_window(x, y, x + width - 1, y + height - 1)
+ self._write(None, buffer)
+
+ def rect(self, x, y, w, h, color):
+ """
+ Draw a rectangle at the given location, size and color.
+
+ Args:
+ x (int): Top left corner x coordinate
+ y (int): Top left corner y coordinate
+ width (int): Width in pixels
+ height (int): Height in pixels
+ color (int): 565 encoded color
+ """
+ self.hline(x, y, w, color)
+ self.vline(x, y, h, color)
+ self.vline(x + w - 1, y, h, color)
+ self.hline(x, y + h - 1, w, color)
+
+ def fill_rect(self, x, y, width, height, color):
+ """
+ Draw a rectangle at the given location, size and filled with color.
+
+ Args:
+ x (int): Top left corner x coordinate
+ y (int): Top left corner y coordinate
+ width (int): Width in pixels
+ height (int): Height in pixels
+ color (int): 565 encoded color
+ """
+ self._set_window(x, y, x + width - 1, y + height - 1)
+ chunks, rest = divmod(width * height, _BUFFER_SIZE)
+ pixel = struct.pack(
+ _ENCODE_PIXEL_SWAPPED if self.needs_swap else _ENCODE_PIXEL, color
+ )
+ GPIO.output(self.dc,GPIO.HIGH)
+ if chunks:
+ data = pixel * _BUFFER_SIZE
+ for _ in range(chunks):
+ self._write(None, data)
+ if rest:
+ self._write(None, pixel * rest)
+
+ def fill(self, color):
+ """
+ Fill the entire FrameBuffer with the specified color.
+
+ Args:
+ color (int): 565 encoded color
+ """
+ self.fill_rect(0, 0, self.width, self.height, color)
+
+ def line(self, x0, y0, x1, y1, color):
+ """
+ Draw a single pixel wide line starting at x0, y0 and ending at x1, y1.
+
+ Args:
+ x0 (int): Start point x coordinate
+ y0 (int): Start point y coordinate
+ x1 (int): End point x coordinate
+ y1 (int): End point y coordinate
+ color (int): 565 encoded color
+ """
+ steep = abs(y1 - y0) > abs(x1 - x0)
+ if steep:
+ x0, y0 = y0, x0
+ x1, y1 = y1, x1
+ if x0 > x1:
+ x0, x1 = x1, x0
+ y0, y1 = y1, y0
+ dx = x1 - x0
+ dy = abs(y1 - y0)
+ err = dx // 2
+ ystep = 1 if y0 < y1 else -1
+ while x0 <= x1:
+ if steep:
+ self.pixel(y0, x0, color)
+ else:
+ self.pixel(x0, y0, color)
+ err -= dy
+ if err < 0:
+ y0 += ystep
+ err += dx
+ x0 += 1
+
+ def vscrdef(self, tfa, vsa, bfa):
+ """
+ Set Vertical Scrolling Definition.
+
+ To scroll a 135x240 display these values should be 40, 240, 40.
+ There are 40 lines above the display that are not shown followed by
+ 240 lines that are shown followed by 40 more lines that are not shown.
+ You could write to these areas off display and scroll them into view by
+ changing the TFA, VSA and BFA values.
+
+ Args:
+ tfa (int): Top Fixed Area
+ vsa (int): Vertical Scrolling Area
+ bfa (int): Bottom Fixed Area
+ """
+ self._write(_ST7789_VSCRDEF, struct.pack(">HHH", tfa, vsa, bfa))
+
+ def vscsad(self, vssa):
+ """
+ Set Vertical Scroll Start Address of RAM.
+
+ Defines which line in the Frame Memory will be written as the first
+ line after the last line of the Top Fixed Area on the display
+
+ Example:
+
+ for line in range(40, 280, 1):
+ tft.vscsad(line)
+ utime.sleep(0.01)
+
+ Args:
+ vssa (int): Vertical Scrolling Start Address
+
+ """
+ self._write(_ST7789_VSCSAD, struct.pack(">H", vssa))
+
+ # @micropython.viper
+ # @staticmethod
+ # def _pack8(glyphs, idx: uint, fg_color: uint, bg_color: uint):
+ # buffer = bytearray(128)
+ # bitmap = ptr16(buffer)
+ # glyph = ptr8(glyphs)
+
+ # for i in range(0, 64, 8):
+ # byte = glyph[idx]
+ # bitmap[i] = fg_color if byte & _BIT7 else bg_color
+ # bitmap[i + 1] = fg_color if byte & _BIT6 else bg_color
+ # bitmap[i + 2] = fg_color if byte & _BIT5 else bg_color
+ # bitmap[i + 3] = fg_color if byte & _BIT4 else bg_color
+ # bitmap[i + 4] = fg_color if byte & _BIT3 else bg_color
+ # bitmap[i + 5] = fg_color if byte & _BIT2 else bg_color
+ # bitmap[i + 6] = fg_color if byte & _BIT1 else bg_color
+ # bitmap[i + 7] = fg_color if byte & _BIT0 else bg_color
+ # idx += 1
+
+ # return buffer
+
+ # @micropython.viper
+ # @staticmethod
+ # def _pack16(glyphs, idx: uint, fg_color: uint, bg_color: uint):
+ # """
+ # Pack a character into a byte array.
+
+ # Args:
+ # char (str): character to pack
+
+ # Returns:
+ # 128 bytes: character bitmap in color565 format
+ # """
+
+ # buffer = bytearray(256)
+ # bitmap = ptr16(buffer)
+ # glyph = ptr8(glyphs)
+
+ # for i in range(0, 128, 16):
+ # byte = glyph[idx]
+
+ # bitmap[i] = fg_color if byte & _BIT7 else bg_color
+ # bitmap[i + 1] = fg_color if byte & _BIT6 else bg_color
+ # bitmap[i + 2] = fg_color if byte & _BIT5 else bg_color
+ # bitmap[i + 3] = fg_color if byte & _BIT4 else bg_color
+ # bitmap[i + 4] = fg_color if byte & _BIT3 else bg_color
+ # bitmap[i + 5] = fg_color if byte & _BIT2 else bg_color
+ # bitmap[i + 6] = fg_color if byte & _BIT1 else bg_color
+ # bitmap[i + 7] = fg_color if byte & _BIT0 else bg_color
+ # idx += 1
+
+ # byte = glyph[idx]
+ # bitmap[i + 8] = fg_color if byte & _BIT7 else bg_color
+ # bitmap[i + 9] = fg_color if byte & _BIT6 else bg_color
+ # bitmap[i + 10] = fg_color if byte & _BIT5 else bg_color
+ # bitmap[i + 11] = fg_color if byte & _BIT4 else bg_color
+ # bitmap[i + 12] = fg_color if byte & _BIT3 else bg_color
+ # bitmap[i + 13] = fg_color if byte & _BIT2 else bg_color
+ # bitmap[i + 14] = fg_color if byte & _BIT1 else bg_color
+ # bitmap[i + 15] = fg_color if byte & _BIT0 else bg_color
+ # idx += 1
+
+ # return buffer
+
+ def _text8(self, font, text, x0, y0, fg_color=WHITE, bg_color=BLACK):
+ """
+ Internal method to write characters with width of 8 and
+ heights of 8 or 16.
+
+ Args:
+ font (module): font module to use
+ text (str): text to write
+ x0 (int): column to start drawing at
+ y0 (int): row to start drawing at
+ color (int): 565 encoded color to use for characters
+ background (int): 565 encoded color to use for background
+ """
+
+ for char in text:
+ ch = ord(char)
+ if (
+ font.FIRST <= ch < font.LAST
+ and x0 + font.WIDTH <= self.width
+ and y0 + font.HEIGHT <= self.height
+ ):
+ if font.HEIGHT == 8:
+ passes = 1
+ size = 8
+ each = 0
+ else:
+ passes = 2
+ size = 16
+ each = 8
+
+ for line in range(passes):
+ idx = (ch - font.FIRST) * size + (each * line)
+ buffer = self._pack8(font.FONT, idx, fg_color, bg_color)
+ self.blit_buffer(buffer, x0, y0 + 8 * line, 8, 8)
+
+ x0 += 8
+
+ # def _text16(self, font, text, x0, y0, fg_color=WHITE, bg_color=BLACK):
+ # """
+ # Internal method to draw characters with width of 16 and heights of 16
+ # or 32.
+
+ # Args:
+ # font (module): font module to use
+ # text (str): text to write
+ # x0 (int): column to start drawing at
+ # y0 (int): row to start drawing at
+ # color (int): 565 encoded color to use for characters
+ # background (int): 565 encoded color to use for background
+ # """
+
+ # for char in text:
+ # ch = ord(char)
+ # if (
+ # font.FIRST <= ch < font.LAST
+ # and x0 + font.WIDTH <= self.width
+ # and y0 + font.HEIGHT <= self.height
+ # ):
+ # each = 16
+ # if font.HEIGHT == 16:
+ # passes = 2
+ # size = 32
+ # else:
+ # passes = 4
+ # size = 64
+
+ # for line in range(passes):
+ # idx = (ch - font.FIRST) * size + (each * line)
+ # buffer = self._pack16(font.FONT, idx, fg_color, bg_color)
+ # self.blit_buffer(buffer, x0, y0 + 8 * line, 16, 8)
+ # x0 += 16
+
+ def text(self, font, text, x0, y0, color=WHITE, background=BLACK):
+ """
+ Draw text on display in specified font and colors. 8 and 16 bit wide
+ fonts are supported.
+
+ Args:
+ font (module): font module to use.
+ text (str): text to write
+ x0 (int): column to start drawing at
+ y0 (int): row to start drawing at
+ color (int): 565 encoded color to use for characters
+ background (int): 565 encoded color to use for background
+ """
+ fg_color = color if self.needs_swap else ((color << 8) & 0xFF00) | (color >> 8)
+ bg_color = (
+ background
+ if self.needs_swap
+ else ((background << 8) & 0xFF00) | (background >> 8)
+ )
+
+ if font.WIDTH == 8:
+ self._text8(font, text, x0, y0, fg_color, bg_color)
+ else:
+ self._text16(font, text, x0, y0, fg_color, bg_color)
+
+ def bitmap(self, bitmap, x, y, index=0):
+ """
+ Draw a bitmap on display at the specified column and row
+
+ Args:
+ bitmap (bitmap_module): The module containing the bitmap to draw
+ x (int): column to start drawing at
+ y (int): row to start drawing at
+ index (int): Optional index of bitmap to draw from multiple bitmap
+ module
+ """
+ width = bitmap.WIDTH
+ height = bitmap.HEIGHT
+ to_col = x + width - 1
+ to_row = y + height - 1
+ if self.width <= to_col or self.height <= to_row:
+ return
+
+ bitmap_size = height * width
+ buffer_len = bitmap_size * 2
+ bpp = bitmap.BPP
+ bs_bit = bpp * bitmap_size * index # if index > 0 else 0
+ palette = bitmap.PALETTE
+ needs_swap = self.needs_swap
+ buffer = bytearray(buffer_len)
+
+ for i in range(0, buffer_len, 2):
+ color_index = 0
+ for _ in range(bpp):
+ color_index = (color_index << 1) | (
+ (bitmap.BITMAP[bs_bit >> 3] >> (7 - (bs_bit & 7))) & 1
+ )
+ bs_bit += 1
+
+ color = palette[color_index]
+ if needs_swap:
+ buffer[i] = color & 0xFF
+ buffer[i + 1] = color >> 8
+ else:
+ buffer[i] = color >> 8
+ buffer[i + 1] = color & 0xFF
+
+ self._set_window(x, y, to_col, to_row)
+ self._write(None, buffer)
+
+ def pbitmap(self, bitmap, x, y, index=0):
+ """
+ Draw a bitmap on display at the specified column and row one row at a time
+
+ Args:
+ bitmap (bitmap_module): The module containing the bitmap to draw
+ x (int): column to start drawing at
+ y (int): row to start drawing at
+ index (int): Optional index of bitmap to draw from multiple bitmap
+ module
+
+ """
+ width = bitmap.WIDTH
+ height = bitmap.HEIGHT
+ bitmap_size = height * width
+ bpp = bitmap.BPP
+ bs_bit = bpp * bitmap_size * index # if index > 0 else 0
+ palette = bitmap.PALETTE
+ needs_swap = self.needs_swap
+ buffer = bytearray(bitmap.WIDTH * 2)
+
+ for row in range(height):
+ for col in range(width):
+ color_index = 0
+ for _ in range(bpp):
+ color_index <<= 1
+ color_index |= (
+ bitmap.BITMAP[bs_bit // 8] & 1 << (7 - (bs_bit % 8))
+ ) > 0
+ bs_bit += 1
+ color = palette[color_index]
+ if needs_swap:
+ buffer[col * 2] = color & 0xFF
+ buffer[col * 2 + 1] = color >> 8 & 0xFF
+ else:
+ buffer[col * 2] = color >> 8 & 0xFF
+ buffer[col * 2 + 1] = color & 0xFF
+
+ to_col = x + width - 1
+ to_row = y + row
+ if self.width > to_col and self.height > to_row:
+ self._set_window(x, y + row, to_col, to_row)
+ self._write(None, buffer)
+
+ def write(self, font, string, x, y, fg=WHITE, bg=BLACK):
+ """
+ Write a string using a converted true-type font on the display starting
+ at the specified column and row
+
+ Args:
+ font (font): The module containing the converted true-type font
+ s (string): The string to write
+ x (int): column to start writing
+ y (int): row to start writing
+ fg (int): foreground color, optional, defaults to WHITE
+ bg (int): background color, optional, defaults to BLACK
+ """
+ buffer_len = font.HEIGHT * font.MAX_WIDTH * 2
+ buffer = bytearray(buffer_len)
+ fg_hi = fg >> 8
+ fg_lo = fg & 0xFF
+
+ bg_hi = bg >> 8
+ bg_lo = bg & 0xFF
+
+ for character in string:
+ try:
+ char_index = font.MAP.index(character)
+ offset = char_index * font.OFFSET_WIDTH
+ bs_bit = font.OFFSETS[offset]
+ if font.OFFSET_WIDTH > 1:
+ bs_bit = (bs_bit << 8) + font.OFFSETS[offset + 1]
+
+ if font.OFFSET_WIDTH > 2:
+ bs_bit = (bs_bit << 8) + font.OFFSETS[offset + 2]
+
+ char_width = font.WIDTHS[char_index]
+ buffer_needed = char_width * font.HEIGHT * 2
+
+ for i in range(0, buffer_needed, 2):
+ if font.BITMAPS[bs_bit // 8] & 1 << (7 - (bs_bit % 8)) > 0:
+ buffer[i] = fg_hi
+ buffer[i + 1] = fg_lo
+ else:
+ buffer[i] = bg_hi
+ buffer[i + 1] = bg_lo
+
+ bs_bit += 1
+
+ to_col = x + char_width - 1
+ to_row = y + font.HEIGHT - 1
+ if self.width > to_col and self.height > to_row:
+ self._set_window(x, y, to_col, to_row)
+ self._write(None, buffer[:buffer_needed])
+
+ x += char_width
+
+ except ValueError:
+ pass
+
+ def write_width(self, font, string):
+ """
+ Returns the width in pixels of the string if it was written with the
+ specified font
+
+ Args:
+ font (font): The module containing the converted true-type font
+ string (string): The string to measure
+
+ Returns:
+ int: The width of the string in pixels
+
+ """
+ width = 0
+ for character in string:
+ try:
+ char_index = font.MAP.index(character)
+ width += font.WIDTHS[char_index]
+ except ValueError:
+ pass
+
+ return width
+
+ @micropython.native
+ def polygon(self, points, x, y, color, angle=0, center_x=0, center_y=0):
+ """
+ Draw a polygon on the display.
+
+ Args:
+ points (list): List of points to draw.
+ x (int): X-coordinate of the polygon's position.
+ y (int): Y-coordinate of the polygon's position.
+ color (int): 565 encoded color.
+ angle (float): Rotation angle in radians (default: 0).
+ center_x (int): X-coordinate of the rotation center (default: 0).
+ center_y (int): Y-coordinate of the rotation center (default: 0).
+
+ Raises:
+ ValueError: If the polygon has less than 3 points.
+ """
+ if len(points) < 3:
+ raise ValueError("Polygon must have at least 3 points.")
+
+ if angle:
+ cos_a = cos(angle)
+ sin_a = sin(angle)
+ rotated = [
+ (
+ x
+ + center_x
+ + int(
+ (point[0] - center_x) * cos_a - (point[1] - center_y) * sin_a
+ ),
+ y
+ + center_y
+ + int(
+ (point[0] - center_x) * sin_a + (point[1] - center_y) * cos_a
+ ),
+ )
+ for point in points
+ ]
+ else:
+ rotated = [(x + int((point[0])), y + int((point[1]))) for point in points]
+
+ for i in range(1, len(rotated)):
+ self.line(
+ rotated[i - 1][0],
+ rotated[i - 1][1],
+ rotated[i][0],
+ rotated[i][1],
+ color,
+ )
\ No newline at end of file
diff --git a/src/seedsigner/hardware/microsd.py b/src/seedsigner/hardware/microsd.py
index da545b7f..38677d86 100644
--- a/src/seedsigner/hardware/microsd.py
+++ b/src/seedsigner/hardware/microsd.py
@@ -4,7 +4,6 @@ import time
from seedsigner.models.singleton import Singleton
from seedsigner.models.threads import BaseThread
-from seedsigner.models.settings import Settings
logger = logging.getLogger(__name__)
@@ -33,6 +32,8 @@ class MicroSD(Singleton, BaseThread):
@property
def is_inserted(self):
+ from seedsigner.models.settings import Settings # Import here to avoid circular import issues
+
if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
return os.path.exists(MicroSD.MOUNT_POINT)
else:
@@ -47,6 +48,7 @@ class MicroSD(Singleton, BaseThread):
def run(self):
from seedsigner.controller import Controller
from seedsigner.gui.toast import SDCardStateChangeToastManagerThread
+ from seedsigner.models.settings import Settings # Import here to avoid circular import issues
action = ""
# explicitly only microsd add/remove detection in seedsigner-os
diff --git a/src/seedsigner/hardware/pivideostream.py b/src/seedsigner/hardware/pivideostream.py
index 06a1ebb7..89c8754f 100644
--- a/src/seedsigner/hardware/pivideostream.py
+++ b/src/seedsigner/hardware/pivideostream.py
@@ -42,7 +42,7 @@ class PiVideoStream:
self.rawCapture.truncate(0)
# if the thread indicator variable is set, stop the thread
- # and resource camera resources
+ # and release camera resources
if self.should_stop:
logger.info("PiVideoStream: closing everything")
self.stream.close()
diff --git a/src/seedsigner/helpers/embit_utils.py b/src/seedsigner/helpers/embit_utils.py
index 1754b080..80553799 100644
--- a/src/seedsigner/helpers/embit_utils.py
+++ b/src/seedsigner/helpers/embit_utils.py
@@ -44,7 +44,7 @@ def get_standard_derivation_path(network: str = SettingsConstants.MAINNET, walle
elif wallet_type == SettingsConstants.MULTISIG:
if script_type == SettingsConstants.LEGACY_P2PKH:
- return f"m/45'" #BIP45
+ return f"m/45'" #BIP-45
elif script_type == SettingsConstants.NESTED_SEGWIT:
return f"m/48'/{network_path}/0'/1'"
elif script_type == SettingsConstants.NATIVE_SEGWIT:
@@ -104,6 +104,14 @@ def get_multisig_address(descriptor: Descriptor, index: int = 0, is_change: bool
+def get_multisig_policy(descriptor: Descriptor) -> tuple:
+ """Extract (threshold, n) from a basic multisig descriptor."""
+ if not descriptor.is_basic_multisig:
+ raise ValueError(f"Expected a basic multisig descriptor, got: {descriptor.brief_policy}")
+ return (str(descriptor.miniscript.args[0]), str(len(descriptor.keys)))
+
+
+
def get_embit_network_name(settings_name):
""" Convert SeedSigner SettingsConstants for `network` to embit's NETWORK key """
lookup = {
diff --git a/src/seedsigner/helpers/mnemonic_generation.py b/src/seedsigner/helpers/mnemonic_generation.py
index b3e90680..444c3fed 100644
--- a/src/seedsigner/helpers/mnemonic_generation.py
+++ b/src/seedsigner/helpers/mnemonic_generation.py
@@ -110,14 +110,3 @@ def get_partial_final_word(coin_flips: str, wordlist_language_code: str = Settin
wordlist_index = int(binary_string, 2)
return Seed.get_wordlist(wordlist_language_code)[wordlist_index]
-
-
-
-# Note: This currently isn't being used since we're now chaining hashed bytes for the
-# image-based entropy and aren't just ingesting a single image.
-def generate_mnemonic_from_image(image, wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> list[str]:
- import hashlib
- hash = hashlib.sha256(image.tobytes())
-
- # Return as a list
- return bip39.mnemonic_from_bytes(hash.digest(), wordlist=Seed.get_wordlist(wordlist_language_code)).split()
diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py
index 1135a140..6a2b2d23 100644
--- a/src/seedsigner/models/decode_qr.py
+++ b/src/seedsigner/models/decode_qr.py
@@ -2,6 +2,7 @@ import base64
import json
import logging
import re
+import zlib
from binascii import a2b_base64, b2a_base64
from enum import IntEnum
@@ -11,6 +12,7 @@ from pyzbar.pyzbar import ZBarSymbol
from urtypes.crypto import PSBT as UR_PSBT
from urtypes.crypto import Account, Output
from urtypes.bytes import Bytes
+from base64 import b32encode, b32decode
from seedsigner.helpers.ur2.ur_decoder import URDecoder
from seedsigner.models.qr_type import QRType
@@ -74,6 +76,9 @@ class DecodeQR:
elif self.qr_type == QRType.PSBT__BASE43:
self.decoder = Base43PsbtQrDecoder() # Single Segment Base43
+ elif self.qr_type == QRType.PSBT__BBQR:
+ self.decoder = BBQRPsbtQrDecoder() # BBQr Decoder
+
elif self.qr_type in [QRType.SEED__SEEDQR, QRType.SEED__COMPACTSEEDQR, QRType.SEED__MNEMONIC, QRType.SEED__FOUR_LETTER_MNEMONIC, QRType.SEED__UR2]:
self.decoder = SeedQrDecoder(wordlist_language_code=self.wordlist_language_code)
@@ -229,7 +234,7 @@ class DecodeQR:
if self.qr_type in [QRType.PSBT__UR2, QRType.OUTPUT__UR, QRType.ACCOUNT__UR, QRType.BYTES__UR]:
return int(self.decoder.estimated_percent_complete(weight_mixed_frames=weight_mixed_frames) * 100)
- elif self.qr_type in [QRType.PSBT__SPECTER]:
+ elif self.qr_type in [QRType.PSBT__SPECTER, QRType.PSBT__BBQR]:
if self.decoder.total_segments == None:
return 0
return int((self.decoder.collected_segments / self.decoder.total_segments) * 100)
@@ -262,6 +267,7 @@ class DecodeQR:
QRType.PSBT__SPECTER,
QRType.PSBT__BASE64,
QRType.PSBT__BASE43,
+ QRType.PSBT__BBQR,
]
@@ -326,9 +332,6 @@ class DecodeQR:
@staticmethod
def detect_segment_type(s, wordlist_language_code=None):
- # print("-------------- DecodeQR.detect_segment_type --------------")
- # print(type(s))
- # print(len(s))
try:
# Convert to str data
@@ -338,6 +341,9 @@ class DecodeQR:
# TODO: Convert the test suite rather than handle here?
s = s.decode('utf-8')
+ logger.debug(f"segment string: {s}")
+ logger.debug(f"segment string length: {len(s)}")
+
# PSBT
if re.search("^UR:CRYPTO-PSBT/", s, re.IGNORECASE):
return QRType.PSBT__UR2
@@ -357,6 +363,9 @@ class DecodeQR:
elif DecodeQR.is_base64_psbt(s):
return QRType.PSBT__BASE64
+ elif re.search(r"^B\$[2HZ]P[0-9A-Z]{4}", s): # https://github.com/coinkite/BBQr/blob/master/BBQr.md#spliting-the-data
+ return QRType.PSBT__BBQR
+
# Wallet Descriptor
desc_str = s.replace("\n","").replace(" ","")
if re.search(r'^p(\d+)of(\d+) ', s, re.IGNORECASE):
@@ -398,11 +407,11 @@ class DecodeQR:
_4LETTER_WORDLIST = []
if all(x in wordlist for x in s.strip().split(" ")):
- # checks if all words in list are in bip39 word list
+ # checks if all words in list are in BIP-39 word list
return QRType.SEED__MNEMONIC
elif all(x in _4LETTER_WORDLIST for x in s.strip().split(" ")):
- # checks if all 4 letter words are in list are in 4 letter bip39 word list
+ # checks if all 4 letter words are in list are in 4 letter BIP-39 word list
return QRType.SEED__FOUR_LETTER_MNEMONIC
elif DecodeQR.is_base43_psbt(s):
@@ -661,8 +670,9 @@ class BaseAnimatedQrDecoder(BaseQrDecoder):
elif self.total_segments != self.total_segment_nums(segment):
raise Exception('Segment total changed unexpectedly')
- if self.segments[self.current_segment_num(segment) - 1] == None:
- self.segments[self.current_segment_num(segment) - 1] = self.parse_segment(segment)
+ current_segment_num = self.current_segment_num(segment)
+ if self.segments[current_segment_num - 1] == None:
+ self.segments[current_segment_num - 1] = self.parse_segment(segment)
self.collected_segments += 1
if self.total_segments == self.collected_segments:
if self.is_valid:
@@ -711,6 +721,59 @@ class SpecterPsbtQrDecoder(BaseAnimatedQrDecoder):
+class BBQRPsbtQrDecoder(BaseAnimatedQrDecoder):
+ """
+ Used to decode BBQR Animated PSBT encoding.
+ """
+ def __init__(self):
+ super().__init__()
+ self.encoding = None
+
+
+ def get_data(self) -> str:
+ logger.debug("BBQRPsbtQrDecoder get_data")
+ data = "".join(self.segments)
+ if self.complete and self.encoding:
+ if self.encoding == 'H':
+ return b''.join(bytes.fromhex(s) for s in self.segments)
+
+ # base32 decode, but insert padding for API
+ rv = b''
+ for p in self.segments:
+ padding = (8 - (len(p) % 8)) % 8
+ rv += b32decode(p + (padding*'='))
+
+ if self.encoding == 'Z':
+ # decompress
+ z = zlib.decompressobj(wbits=-10)
+ rv = z.decompress(rv)
+ rv += z.flush()
+
+ return rv
+
+ return None
+
+ def current_segment_num(self, segment) -> int:
+ current_segment = int(segment[6:8], 36) + 1
+ logger.debug(f"BBQRPsbtQrDecoder current_segment_num {current_segment}")
+ return current_segment
+
+
+ def total_segment_nums(self, segment) -> int:
+ total_segments = int(segment[4:6], 36)
+ logger.debug(f"BBQRPsbtQrDecoder total_segment_nums {total_segments}")
+ return total_segments
+
+
+ def parse_segment(self, segment) -> str:
+ self.encoding = segment[2]
+ file_type = segment[3]
+ data = segment[8:]
+
+ return data.strip()
+
+
+
class Base64PsbtQrDecoder(BaseSingleFrameQrDecoder):
"""
Decodes single frame base64 encoded qr image.
diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py
index c66e8567..86be8c66 100644
--- a/src/seedsigner/models/encode_qr.py
+++ b/src/seedsigner/models/encode_qr.py
@@ -238,7 +238,11 @@ class BaseSimpleAnimatedQREncoder(BaseQrEncoder):
@dataclass
-class SpecterXPubQrEncoder(BaseSimpleAnimatedQREncoder, BaseXpubQrEncoder):
+class SpecterLegacyXPubQrEncoder(BaseSimpleAnimatedQREncoder, BaseXpubQrEncoder):
+ """
+ Legacy "pXofY" format. Included here for compatibility with much older versions of
+ Specter Desktop. Can probably eventually be removed.
+ """
@property
def qr_max_fragment_size(self):
density_mapping = {
@@ -344,6 +348,9 @@ class UrXpubQrEncoder(BaseFountainQrEncoder, BaseXpubQrEncoder):
return Keypath(arr, self.root.my_fingerprint, len(arr))
origin = derivation_to_keypath(self.derivation)
+
+ # Implemts "use_info" member on HDKey class (urtypes/crypto packages-libs folder) construct,
+ # so if working on TESTNET, Xpub can be exported accordingly. Default case, MAINNET: None value.
self.use_info = None if self.network == SettingsConstants.MAINNET else CoinInfo(type=None, network=1)
self.ur_hdkey = HDKey({ 'key': self.xpub.key.serialize(),
diff --git a/src/seedsigner/models/psbt_parser.py b/src/seedsigner/models/psbt_parser.py
index 7aa99538..6f126099 100644
--- a/src/seedsigner/models/psbt_parser.py
+++ b/src/seedsigner/models/psbt_parser.py
@@ -3,7 +3,8 @@ from binascii import hexlify
from embit import psbt, script, ec, bip32
from embit.descriptor import Descriptor
from embit.networks import NETWORKS
-from embit.psbt import PSBT
+from embit.psbt import PSBT, DerivationPath, InputScope, OutputScope
+from embit.ec import PublicKey
from io import BytesIO
from typing import List
@@ -79,6 +80,9 @@ class PSBTParser():
self._set_root()
+ # Try to fix missing fingerprints before parsing
+ self._fill_missing_fingerprints()
+
rt = self._parse_inputs()
if rt == False:
return False
@@ -372,14 +376,36 @@ class PSBTParser():
the current seed.
"""
seed_fingerprint = seed.get_fingerprint(network)
- for input in psbt.inputs:
- for pub, derivation_path in input.bip32_derivations.items():
- if seed_fingerprint == hexlify(derivation_path.fingerprint).decode():
- return True
+
+ def check_fingerprint_match(public_key: PublicKey, derivation_path_obj: DerivationPath):
+ """Check fingerprint match with missing fingerprint fallback"""
- for pub, (leaf_hashes, derivation_path) in input.taproot_bip32_derivations.items():
- if seed_fingerprint == hexlify(derivation_path.fingerprint).decode():
+ # If exact fingerprint match
+ if hexlify(derivation_path_obj.fingerprint).decode() == seed_fingerprint:
+ return True
+
+ # Missing fingerprint fallback
+ if derivation_path_obj.fingerprint == b"\x00\x00\x00\x00":
+ root = bip32.HDKey.from_seed(seed.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(network)]["xprv"])
+ try:
+ derived_key = root.derive(derivation_path_obj.derivation)
+ return derived_key.key.sec() == public_key.sec() # Public keys match
+ except Exception as e:
+ logger.debug("Fingerprint fallback derive failed: %s", e, exc_info=True)
+ return False
+
+ # Check all derivations in all inputs
+ for input in psbt.inputs:
+ # Check regular BIP32 derivations
+ for public_key, derivation_path_obj in input.bip32_derivations.items():
+ if check_fingerprint_match(public_key, derivation_path_obj):
return True
+
+ # Check Taproot derivations
+ for public_key, (leaf_hashes, derivation_path_obj) in input.taproot_bip32_derivations.items():
+ if check_fingerprint_match(public_key, derivation_path_obj):
+ return True
+
return False
@@ -390,3 +416,58 @@ class PSBTParser():
is_owner = descriptor.owns(output)
# print(f"{self.psbt.tx.vout[i].script_pubkey.address()} | {output.value} | {is_owner}")
return is_owner
+
+
+ def _fill_missing_fingerprints(self):
+ """
+ Fix for when fingerprint is missing (defaults to all zeros). Happens when the user
+ creates a new wallet in an external coordinator but only provides the xpub
+ (fingerprint and derivation path are omitted).
+
+ Filling the missing fingerprints allows SeedSigner to correctly identify inputs /
+ outputs that belong to the signing seed.
+
+ see: https://github.com/SeedSigner/seedsigner/issues/359
+ """
+ if not self.root:
+ return 0
+
+ def _fill_scope(scope: InputScope | OutputScope):
+ """Helper function to fill missing fingerprints in a scope (input/output)"""
+ signing_seed_fingerprint = self.root.child(0).fingerprint
+
+ # Helper function to check and fix fingerprint
+ def _get_updated_fingerprint(public_key: PublicKey, derivation_path_obj: DerivationPath) -> DerivationPath | None:
+ if derivation_path_obj.fingerprint != b"\x00\x00\x00\x00":
+ return None
+
+ # Derive the public key from the currently loaded seed using the derivation
+ # contained in the PSBT. If the derived public key exactly matches
+ # the PSBT-provided public key, we can be confident that this input/output
+ # is owned by the signing seed. In that case we populate the missing (zero)
+ # fingerprint with the signing seed's master fingerprint so downstream
+ # parsing/signing can treat it as owned by this seed.
+ derived_key = self.root.derive(derivation_path_obj.derivation)
+ if derived_key.key.sec() == public_key.sec():
+ return DerivationPath(signing_seed_fingerprint, derivation_path_obj.derivation)
+ return None
+
+ # Handle regular BIP32 derivations
+ for public_key, derivation_path_obj in list(scope.bip32_derivations.items()):
+ new_derivation = _get_updated_fingerprint(public_key, derivation_path_obj)
+ if new_derivation:
+ scope.bip32_derivations[public_key] = new_derivation
+ logger.debug(f"Filled missing fingerprint for pubkey {public_key.sec().hex()} derivation {bip32.path_to_str(derivation_path_obj.derivation)}")
+
+ # Handle Taproot derivations
+ for public_key, (leaf_hashes, derivation_path_obj) in list(scope.taproot_bip32_derivations.items()):
+ new_derivation = _get_updated_fingerprint(public_key, derivation_path_obj)
+ if new_derivation:
+ scope.taproot_bip32_derivations[public_key] = (leaf_hashes, new_derivation)
+ logger.debug(f"Filled missing fingerprint for pubkey {public_key.sec().hex()} derivation {bip32.path_to_str(derivation_path_obj.derivation)}")
+
+ for inp in self.psbt.inputs:
+ _fill_scope(inp)
+
+ for out in self.psbt.outputs:
+ _fill_scope(out)
\ No newline at end of file
diff --git a/src/seedsigner/models/qr_type.py b/src/seedsigner/models/qr_type.py
index 6ec1f5ec..bd678f7e 100644
--- a/src/seedsigner/models/qr_type.py
+++ b/src/seedsigner/models/qr_type.py
@@ -6,6 +6,7 @@ class QRType:
PSBT__SPECTER = "psbt__specter"
PSBT__BASE43 = "psbt__base43"
PSBT__UR2 = "psbt__ur2"
+ PSBT__BBQR = "psbt__bbqr"
SEED__SEEDQR = "seed__seedqr"
SEED__COMPACTSEEDQR = "seed__compactseedqr"
diff --git a/src/seedsigner/models/settings.py b/src/seedsigner/models/settings.py
index 3c26d797..14741e67 100644
--- a/src/seedsigner/models/settings.py
+++ b/src/seedsigner/models/settings.py
@@ -81,6 +81,10 @@ class Settings(Singleton):
for entry in data.split()[split_index:]:
abbreviated_name, value = entry.split("=")
+ # Empty values ("some_setting= other_setting=E") are invalid
+ if value == "":
+ raise InvalidSettingsQRData(f"{abbreviated_name} cannot be empty")
+
# Parse multi-value settings; integer-ize where needed
if "," in value:
values_updated = []
@@ -103,6 +107,7 @@ class Settings(Singleton):
values = [value]
else:
values = value
+
for v in values:
if v not in [opt[0] for opt in settings_entry.selection_options]:
if settings_entry.attr_name == SettingsConstants.SETTING__PERSISTENT_SETTINGS and v == SettingsConstants.OPTION__ENABLED:
@@ -123,7 +128,8 @@ class Settings(Singleton):
def save(self):
- if self._data[SettingsConstants.SETTING__PERSISTENT_SETTINGS] == SettingsConstants.OPTION__ENABLED:
+ from seedsigner.hardware.microsd import MicroSD
+ if self._data[SettingsConstants.SETTING__PERSISTENT_SETTINGS] == SettingsConstants.OPTION__ENABLED and MicroSD.get_instance().is_inserted:
with open(Settings.SETTINGS_FILENAME, 'w') as settings_file:
json.dump(self._data, settings_file, indent=4)
# SeedSignerOS makes removing the microsd possible, flush and then fsync forces persistent settings to disk
@@ -153,8 +159,13 @@ class Settings(Singleton):
# Clean the incoming data, if necessary
if entry.type == SettingsConstants.TYPE__MULTISELECT:
if type(new_settings[entry.attr_name]) == str:
- # Break comma-separated SettingsQR input into List
- new_settings[entry.attr_name] = new_settings[entry.attr_name].split(",")
+ # Break comma-separated multiselect options into List; avoid empty
+ # values.
+ new_settings[entry.attr_name] = [value for value in new_settings[entry.attr_name].split(",") if value.strip()]
+
+ if not new_settings[entry.attr_name]:
+ # Multiselect cannot be empty; load defaults to avoid issues
+ new_settings[entry.attr_name] = entry.default_value
for key, value in new_settings.items():
self.set_value(key, value)
@@ -191,13 +202,16 @@ class Settings(Singleton):
self.load_locale()
- def get_value(self, attr_name: str):
+ def get_value(self, attr_name: str, default_if_none: bool = None):
"""
Returns the attr's current value.
Note that for multiselect, the current value is a List.
"""
if attr_name not in self._data:
+ if default_if_none:
+ return SettingsDefinition.get_settings_entry(attr_name).default_value
+
raise Exception(f"Setting for {attr_name} not found")
return self._data[attr_name]
diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py
index 08652519..52b31c87 100644
--- a/src/seedsigner/models/settings_definition.py
+++ b/src/seedsigner/models/settings_definition.py
@@ -1,8 +1,14 @@
+import os
+import pathlib
from dataclasses import dataclass
from typing import Any, List
from seedsigner.helpers.l10n import mark_for_translation as _mft
+import logging
+logger = logging.getLogger(__name__)
+
+
class SettingsConstants:
# Basic defaults
@@ -33,44 +39,181 @@ class SettingsConstants:
]
# User-facing selection options
- COORDINATOR__BLUE_WALLET = "bw"
- COORDINATOR__NUNCHUK = "nun"
- COORDINATOR__SPARROW = "spa"
- COORDINATOR__SPECTER_DESKTOP = "spd"
- COORDINATOR__KEEPER = "kpr"
- ALL_COORDINATORS = [
- (COORDINATOR__BLUE_WALLET, "BlueWallet"),
- (COORDINATOR__NUNCHUK, "Nunchuk"),
- (COORDINATOR__SPARROW, "Sparrow"),
- (COORDINATOR__SPECTER_DESKTOP, "Specter Desktop"),
- (COORDINATOR__KEEPER, "Keeper"),
+ XPUB_QR_FORMAT__UR_CRYPTO_ACCOUNT = "urca"
+ XPUB_QR_FORMAT__STATIC = "sta"
+ XPUB_QR_FORMAT__SPECTER_LEGACY = "spl"
+ ALL_XPUB_QR_FORMATS = [
+ # TRANSLATOR_NOTE: QR code format option; "default" = this is the format most wallets use
+ (XPUB_QR_FORMAT__UR_CRYPTO_ACCOUNT, _mft("Animated (default)")),
+
+ # TRANSLATOR_NOTE: QR code format option (static = single frame, not animated)
+ (XPUB_QR_FORMAT__STATIC, _mft("Static")),
+
+ # TRANSLATOR_NOTE: QR code format option: old format that Specter Desktop used to use
+ (XPUB_QR_FORMAT__SPECTER_LEGACY, _mft("Specter legacy")),
]
+ # Over-specifying current and possible future locales to reduce/eliminate main repo
+ # changes when adding/testing new languages.
LOCALE__ARABIC = "ar"
+ LOCALE__BENGALI = "bn"
+ LOCALE__BULGARIAN = "bg"
+ LOCALE__CATALAN = "ca"
+ LOCALE__CHINESE_SIMPLIFIED = "zh_Hans_CN"
+ LOCALE__CHINESE_TRADITIONAL = "zh_Hant_TW"
+ LOCALE__CROATIAN = "hr"
LOCALE__CZECH = "cs"
+ LOCALE__DANISH = "da"
+ LOCALE__DUTCH = "nl"
+ LOCALE__EGYPTIAN = "ar_EG"
LOCALE__ENGLISH = "en"
+ LOCALE__ESTONIAN = "et"
+ LOCALE__FINNISH = "fi"
LOCALE__FRENCH = "fr"
+ LOCALE__GAELIC = "gd"
LOCALE__GERMAN = "de"
+ LOCALE__GREEK = "el"
+ LOCALE__GUJARATI = "gu"
+ LOCALE__HAUSA = "ha"
LOCALE__HEBREW = "he"
+ LOCALE__HINDI = "hi"
+ LOCALE__HUNGARIAN = "hu"
+ LOCALE__INDONESIAN = "id"
+ LOCALE__ITALIAN = "it"
LOCALE__JAPANESE = "ja"
- LOCALE__KOREAN = "kr"
- LOCALE__PORTUGUESE = "pt"
+ LOCALE__JAVANESE = "jv"
+ LOCALE__KOREAN = "ko"
+ LOCALE__LAO = "lo"
+ LOCALE__LATVIAN = "lv"
+ LOCALE__LITHUANIAN = "lt"
+ LOCALE__MALAY = "ms"
+ LOCALE__MALTESE = "mt"
+ LOCALE__MARATHI = "mr"
+ LOCALE__NORWEGIAN = "no"
+ LOCALE__PERSIAN = "fa"
+ LOCALE__POLISH = "pl"
+ LOCALE__PORTUGUESE_BR = "pt_BR"
+ LOCALE__PORTUGUESE_PT = "pt_PT"
+ LOCALE__PUNJABI = "pa"
+ LOCALE__ROMANIAN = "ro"
LOCALE__RUSSIAN = "ru"
+ LOCALE__SLOVAK = "sk"
+ LOCALE__SLOVENIAN = "sl"
LOCALE__SPANISH = "es"
- # Do not wrap for translation; always present each language in its native form
- ALL_LOCALES = [
- # (LOCALE__ARABIC, "Arabic"),
- # (LOCALE__CZECH, "čeština"),
- (LOCALE__ENGLISH, "English"),
- # (LOCALE__FRENCH, "Français"),
- # (LOCALE__GERMAN, "Deutsch"),
- # (LOCALE__HEBREW, "Hebrew"),
- # (LOCALE__JAPANESE, "Japanese"),
- # (LOCALE__KOREAN, "Korean"),
- # (LOCALE__PORTUGUESE, "Português"),
- # (LOCALE__RUSSIAN, "русский"),
- (LOCALE__SPANISH, "Español"),
- ]
+ LOCALE__SWEDISH = "sv"
+ LOCALE__TAGALOG = "tl"
+ LOCALE__TAMIL = "ta"
+ LOCALE__TELUGU = "te"
+ LOCALE__THAI = "th"
+ LOCALE__TURKISH = "tr"
+ LOCALE__UKRANIAN = "uk"
+ LOCALE__URDU = "ur"
+ LOCALE__VIETNAMESE = "vi"
+
+ # Do not wrap for translation. Present each language in its native form (i.e. either
+ # using its native chars or how they write it in Latin chars; e.g. Spanish is listed
+ # and sorted as "Español").
+ # Sort fully-vetted languages first, then beta languages, then the "placeholders /
+ # coming soon" languages.
+ # Sort by native form when written in Latin chars, otherwise sort by English name.
+ # Include English name in parens for languages that don't use Latin chars.
+ # Include region/country in parens for specific dialects (e.g. "Português (Brasil)").
+ # Note that dicts preserve insertion order as of Python 3.7.
+ ALL_LOCALES = {
+ # --------- Fully supported languages -------------------------------------------
+ LOCALE__CATALAN: "Català",
+ LOCALE__GERMAN: "Deutsch",
+ LOCALE__ENGLISH: "English",
+ LOCALE__SPANISH: "Español",
+ LOCALE__FRENCH: "Français",
+ LOCALE__ITALIAN: "Italiano",
+ LOCALE__DUTCH: "Nederlands",
+
+ # --------- Beta languages ------------------------------------------------------
+ LOCALE__CZECH: "(beta) Čeština",
+ LOCALE__CHINESE_SIMPLIFIED: "(beta) 简体中文 (Chinese Simplified)",
+ LOCALE__HINDI: "(beta) हिन्दी (Hindi)",
+ LOCALE__INDONESIAN: "(beta) Indonesia (Indonesian)",
+ LOCALE__JAPANESE: "(beta) 日本語 (Japanese)",
+ LOCALE__KOREAN: "(beta) 한국어 (Korean)",
+ LOCALE__PERSIAN: "(beta) فارسی (Persian)",
+ LOCALE__POLISH: "(beta) Polski",
+ LOCALE__PORTUGUESE_BR: "(beta) Português (Brasil)",
+ LOCALE__THAI: "(beta) ไทย (Thai)",
+ LOCALE__VIETNAMESE: "(beta) Tiếng Việt (Vietnamese)",
+
+ # --------- In Progress Languages -----------------------------------------------
+ LOCALE__GREEK: "(incomplete) Ελληνικά (Greek)", # OpenSans includes Greek chars
+ LOCALE__NORWEGIAN: "(incomplete) Norsk (Norwegian)",
+ LOCALE__RUSSIAN: "(incomplete) русский (Russian)", # OpenSans includes cyrillic chars
+ LOCALE__TURKISH: "(incomplete) Türkçe (Turkish)",
+
+ # --------- Placeholders / Coming soon ------------------------------------------
+ # Commented out options require explicit additional font support.
+ # -------------------------------------------------------------------------------
+ LOCALE__ARABIC: "العربية (Arabic)",
+ # LOCALE__BENGALI: "বাংলা (Bengali)",
+ LOCALE__BULGARIAN: "български (Bulgarian)", # OpenSans includes cyrillic chars
+ # LOCALE__CHINESE_TRADITIONAL: "繁體中文 (Chinese Traditional)",
+ LOCALE__DANISH: "Dansk",
+ LOCALE__ESTONIAN: "Eesti",
+ # LOCALE__EGYPTIAN: "مصرى (Egyptian)",
+ LOCALE__GAELIC: "Gaeilge",
+ # LOCALE__GUJARATI: "ગુજરાતી (Gujarati)",
+ LOCALE__HAUSA: "Hausa",
+ # LOCALE__HEBREW: "עברית (Hebrew)",
+ LOCALE__CROATIAN: "Hrvatski",
+ LOCALE__JAVANESE: "Jawa (Javanese)",
+ # LOCALE__LAO: "ລາວ (Lao)",
+ LOCALE__LATVIAN: "Latviešu",
+ LOCALE__LITHUANIAN: "Lietuvių",
+ LOCALE__HUNGARIAN: "Magyar",
+ LOCALE__MALAY: "Melayu",
+ LOCALE__MALTESE: "Malti",
+ # LOCALE__MARATHI: "मराठी (Marathi)",
+ LOCALE__PORTUGUESE_PT: "Português (Portugal)",
+ # LOCALE__PUNJABI: "ਪੰਜਾਬੀ (Punjabi)",
+ LOCALE__ROMANIAN: "Română",
+ LOCALE__SLOVAK: "Slovenčina",
+ LOCALE__SLOVENIAN: "Slovenščina",
+ LOCALE__FINNISH: "Suomi",
+ LOCALE__SWEDISH: "Svenska",
+ LOCALE__TAGALOG: "Tagalog",
+ # LOCALE__TAMIL: "தமிழ் (Tamil)",
+ # LOCALE__TELUGU: "తెలుగు (Telugu)",
+ LOCALE__UKRANIAN: "українська (Ukranian)", # OpenSans includes cyrillic chars
+ # LOCALE__URDU: "اردو (Urdu)",
+ }
+
+
+ @classmethod
+ def get_detected_languages(cls) -> list[tuple[str, str]]:
+ """
+ Return a list of tuples of language codes and their native names.
+
+ Scans the filesystem to autodiscover which language codes are onboard.
+ """
+ # Back out from the models/ dir to reach the seedsigner root
+ models_dir = pathlib.Path(__file__).parent.resolve()
+ seedsigner_root = models_dir.parent.resolve()
+
+ # Pre-load English since there's no "en" entry in the translations folder; also
+ # it should always appear first in the list anyway.
+ detected_languages = [(cls.LOCALE__ENGLISH, cls.ALL_LOCALES[cls.LOCALE__ENGLISH])]
+
+ locales_present = set()
+ for root, dirs, files in os.walk(os.path.join(seedsigner_root, "resources", "seedsigner-translations", "l10n")):
+ for file in [f for f in files if f.endswith(".mo")]:
+ # `root` will be [...]seedsigner/resources/seedsigner-translations/l10n/pt_BR/LC_MESSAGES
+ # Isolate the language code from the path
+ locales_present.add(root.rsplit(os.sep, 2)[-2])
+
+ for locale in cls.ALL_LOCALES.keys():
+ if locale in locales_present:
+ detected_languages.append((locale, cls.ALL_LOCALES[locale]))
+
+ return detected_languages
+
BTC_DENOMINATION__BTC = "btc"
BTC_DENOMINATION__SATS = "sats"
@@ -153,6 +296,17 @@ class SettingsConstants:
(CUSTOM_DERIVATION, _mft("Custom Derivation")),
]
+ MICROSD_TOAST_TIMER_DISABLED = "D"
+ MICROSD_TOAST_TIMER_FIVE_SECONDS = "E"
+ MICROSD_TOAST_TIMER_FOREVER = "inf"
+ ALL_MICROSD_TOAST_TIMERS = [
+ (MICROSD_TOAST_TIMER_DISABLED, _mft("Disabled")),
+ # TRANSLATOR_NOTE: MicroSD notification duration setting - Display notification for 5 seconds
+ (MICROSD_TOAST_TIMER_FIVE_SECONDS, _mft("5 seconds")),
+ # TRANSLATOR_NOTE: MicroSD notification duration setting - Display notification until the SD card is removed
+ (MICROSD_TOAST_TIMER_FOREVER, _mft("Until SD removed"))
+ ]
+
WORDLIST_LANGUAGE__ENGLISH = "en"
WORDLIST_LANGUAGE__CHINESE_SIMPLIFIED = "zh_Hans_CN"
WORDLIST_LANGUAGE__CHINESE_TRADITIONAL = "zh_Hant_TW"
@@ -172,18 +326,19 @@ class SettingsConstants:
# (WORDLIST_LANGUAGE__PORTUGUESE, "Português"),
]
-
# Individual SettingsEntry attr_names
# Note: attr_names are internal constants; do not wrap for translation
SETTING__LOCALE = "locale"
SETTING__WORDLIST_LANGUAGE = "wordlist_language"
SETTING__PERSISTENT_SETTINGS = "persistent_settings"
- SETTING__COORDINATORS = "coordinators"
+ SETTING__XPUB_QR_FORMAT = "xpub_qr"
SETTING__BTC_DENOMINATION = "denomination"
+ SETTING__DISPLAY_CONFIGURATION = "display_config"
+ SETTING__DISPLAY_COLOR_INVERTED = "color_inverted"
+
SETTING__NETWORK = "network"
SETTING__QR_DENSITY = "qr_density"
- SETTING__XPUB_EXPORT = "xpub_export"
SETTING__SIG_TYPES = "sig_types"
SETTING__SCRIPT_TYPES = "script_types"
SETTING__XPUB_DETAILS = "xpub_details"
@@ -197,9 +352,24 @@ class SettingsConstants:
SETTING__DIRE_WARNINGS = "dire_warnings"
SETTING__QR_BRIGHTNESS_TIPS = "qr_brightness_tips"
SETTING__PARTNER_LOGOS = "partner_logos"
+ SETTING__MICROSD_TOAST_TIMER = "microsd_toast_timer"
SETTING__DEBUG = "debug"
+
+ # Hardware config settings
+ DISPLAY_CONFIGURATION__ST7789__240x240 = "st7789_240x240" # default; original Waveshare 1.3" display hat
+ DISPLAY_CONFIGURATION__ST7789__320x240 = "st7789_320x240" # natively portrait dimensions; we apply a 90° rotation
+ DISPLAY_CONFIGURATION__ILI9341__320x240 = "ili9341_320x240" # natively portrait dimensions; we apply a 90° rotation
+ DISPLAY_CONFIGURATION__ILI9486__480x320 = "ili9486_480x320" # natively portrait dimensions; we apply a 90° rotation
+ ALL_DISPLAY_CONFIGURATIONS = [
+ (DISPLAY_CONFIGURATION__ST7789__240x240, "st7789 240x240"),
+ (DISPLAY_CONFIGURATION__ST7789__320x240, "st7789 320x240"),
+ (DISPLAY_CONFIGURATION__ILI9341__320x240, "ili9341 320x240 (beta)"),
+ # (DISPLAY_CONFIGURATION__ILI9486__320x480, "ili9486 480x320"), # TODO: Enable when ili9486 driver performance is improved
+ ]
+
+
# Hidden settings
SETTING__QR_BRIGHTNESS = "qr_background_color"
@@ -213,6 +383,7 @@ class SettingsConstants:
VISIBILITY__GENERAL = "general"
VISIBILITY__ADVANCED = "advanced"
+ VISIBILITY__HARDWARE = "hardware"
VISIBILITY__DEVELOPER = "developer"
VISIBILITY__HIDDEN = "hidden" # For data-only (e.g. custom_derivation), not configurable by the user
@@ -238,7 +409,7 @@ class SettingsConstants:
# Label strings
LABEL__BIP39_PASSPHRASE = _mft("BIP-39 Passphrase")
- # TRANSLATOR_NOTE: Terminology used by Electrum seeds; equivalent to bip39 passphrase
+ # TRANSLATOR_NOTE: Terminology used by Electrum seeds; equivalent to BIP-39 passphrase
custom_extension = _mft("Custom Extension")
LABEL__CUSTOM_EXTENSION = custom_extension
@@ -284,6 +455,9 @@ class SettingsEntry:
elif type(self.default_value) == tuple:
self.default_value = self.default_value[0]
+ if not self.abbreviated_name:
+ self.abbreviated_name = self.attr_name
+
@property
def selection_options_display_names(self) -> List[str]:
@@ -382,10 +556,10 @@ class SettingsDefinition:
abbreviated_name="lang",
display_name=_mft("Language"),
type=SettingsConstants.TYPE__SELECT_1,
- selection_options=SettingsConstants.ALL_LOCALES,
+ selection_options=SettingsConstants.get_detected_languages(),
default_value=SettingsConstants.LOCALE__ENGLISH),
- # TODO: Support other bip-39 wordlist languages! Until then, type == HIDDEN
+ # TODO: Support other BIP-39 wordlist languages! Until then, type == HIDDEN
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
attr_name=SettingsConstants.SETTING__WORDLIST_LANGUAGE,
abbreviated_name="wordlist_lang",
@@ -402,19 +576,6 @@ class SettingsDefinition:
help_text=SettingsConstants.PERSISTENT_SETTINGS__SD_INSERTED__HELP_TEXT,
default_value=SettingsConstants.OPTION__DISABLED),
- SettingsEntry(category=SettingsConstants.CATEGORY__WALLET,
- attr_name=SettingsConstants.SETTING__COORDINATORS,
- abbreviated_name="coords",
- display_name=_mft("Coordinator software"),
- type=SettingsConstants.TYPE__MULTISELECT,
- selection_options=SettingsConstants.ALL_COORDINATORS,
- default_value=[
- SettingsConstants.COORDINATOR__BLUE_WALLET,
- SettingsConstants.COORDINATOR__NUNCHUK,
- SettingsConstants.COORDINATOR__SPARROW,
- SettingsConstants.COORDINATOR__SPECTER_DESKTOP,
- ]),
-
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
attr_name=SettingsConstants.SETTING__BTC_DENOMINATION,
abbreviated_name="denom",
@@ -441,12 +602,6 @@ class SettingsDefinition:
selection_options=SettingsConstants.ALL_DENSITIES,
default_value=SettingsConstants.DENSITY__MEDIUM),
- SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
- attr_name=SettingsConstants.SETTING__XPUB_EXPORT,
- display_name=_mft("Xpub export"),
- visibility=SettingsConstants.VISIBILITY__ADVANCED,
- default_value=SettingsConstants.OPTION__ENABLED),
-
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__SIG_TYPES,
abbreviated_name="sigs",
@@ -465,6 +620,17 @@ class SettingsDefinition:
selection_options=SettingsConstants.ALL_SCRIPT_TYPES,
default_value=[SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT, SettingsConstants.TAPROOT]),
+ SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
+ attr_name=SettingsConstants.SETTING__XPUB_QR_FORMAT,
+ display_name=_mft("Xpub QR format"),
+ visibility=SettingsConstants.VISIBILITY__ADVANCED,
+ type=SettingsConstants.TYPE__MULTISELECT,
+ selection_options=SettingsConstants.ALL_XPUB_QR_FORMATS,
+ default_value=[
+ SettingsConstants.XPUB_QR_FORMAT__UR_CRYPTO_ACCOUNT,
+ SettingsConstants.XPUB_QR_FORMAT__STATIC,
+ ]),
+
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__XPUB_DETAILS,
display_name=_mft("Show xpub details"),
@@ -508,6 +674,14 @@ class SettingsDefinition:
help_text=_mft("Native Segwit only"),
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__DISABLED),
+
+ SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
+ attr_name=SettingsConstants.SETTING__MICROSD_TOAST_TIMER,
+ display_name=_mft("MicroSD notification duration"),
+ type=SettingsConstants.TYPE__SELECT_1,
+ visibility=SettingsConstants.VISIBILITY__ADVANCED,
+ selection_options=SettingsConstants.ALL_MICROSD_TOAST_TIMERS,
+ default_value=SettingsConstants.MICROSD_TOAST_TIMER_FIVE_SECONDS),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__MESSAGE_SIGNING,
@@ -542,6 +716,28 @@ class SettingsDefinition:
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__ENABLED),
+
+ # Hardware config
+ SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
+ attr_name=SettingsConstants.SETTING__DISPLAY_CONFIGURATION,
+ abbreviated_name="disp_conf",
+ # TRANSLATOR_NOTE: Hardware settings option to specify the screen driver (e.g. st7789 vs ili9341)
+ display_name=_mft("Display type"),
+ type=SettingsConstants.TYPE__SELECT_1,
+ visibility=SettingsConstants.VISIBILITY__HARDWARE,
+ selection_options=SettingsConstants.ALL_DISPLAY_CONFIGURATIONS,
+ default_value=SettingsConstants.DISPLAY_CONFIGURATION__ST7789__240x240),
+
+ SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
+ attr_name=SettingsConstants.SETTING__DISPLAY_COLOR_INVERTED,
+ abbreviated_name="rgb_inv",
+ # TRANSLATOR_NOTE: Hardware settings option to invert how the screen driver displays colors.
+ display_name=_mft("Invert colors"),
+ type=SettingsConstants.TYPE__ENABLED_DISABLED,
+ visibility=SettingsConstants.VISIBILITY__HARDWARE,
+ default_value=SettingsConstants.OPTION__DISABLED),
+
+
# Developer options
# TODO: No real Developer options needed yet. Disable for now.
# SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
@@ -585,15 +781,25 @@ class SettingsDefinition:
@classmethod
- def get_defaults(cls) -> dict:
+ def get_defaults(cls, use_abbreviated_name: bool = False, skip_hidden: bool = False) -> dict:
+ """
+ * use_abbreviated_name: Only used by the test suite.
+ * skip_hidden: Only used by the test suite.
+ """
as_dict = {}
for entry in SettingsDefinition.settings_entries:
+ if skip_hidden and entry.visibility == SettingsConstants.VISIBILITY__HIDDEN:
+ continue
+ if not use_abbreviated_name:
+ attr_name = entry.attr_name
+ else:
+ attr_name = entry.abbreviated_name
if type(entry.default_value) == list:
# Must copy the default_value list, otherwise we'll inadvertently change
# defaults when updating these attrs
- as_dict[entry.attr_name] = list(entry.default_value)
+ as_dict[attr_name] = list(entry.default_value)
else:
- as_dict[entry.attr_name] = entry.default_value
+ as_dict[attr_name] = entry.default_value
return as_dict
@@ -606,19 +812,3 @@ class SettingsDefinition:
output["settings_entries"].append(settings_entry.to_dict())
return output
-
-
-
-if __name__ == "__main__":
- import json
- import os
-
- hostname = os.uname()[1]
-
- if hostname == "seedsigner-os":
- output_file = "/mnt/microsd/settings_definition.json"
- else:
- output_file = "settings_definition.json"
-
- with open(output_file, 'w') as json_file:
- json.dump(SettingsDefinition.to_dict(), json_file, indent=4)
diff --git a/src/seedsigner/resources/fonts/NotoSansDevanagari-Regular.ttf b/src/seedsigner/resources/fonts/NotoSansDevanagari-Regular.ttf
new file mode 100644
index 00000000..ab3f4334
Binary files /dev/null and b/src/seedsigner/resources/fonts/NotoSansDevanagari-Regular.ttf differ
diff --git a/src/seedsigner/resources/fonts/RobotoCondensed-Bold.ttf b/src/seedsigner/resources/fonts/RobotoCondensed-Bold.ttf
deleted file mode 100644
index 7fe31289..00000000
Binary files a/src/seedsigner/resources/fonts/RobotoCondensed-Bold.ttf and /dev/null differ
diff --git a/src/seedsigner/resources/fonts/RobotoCondensed-Regular.ttf b/src/seedsigner/resources/fonts/RobotoCondensed-Regular.ttf
deleted file mode 100644
index 62dd61e5..00000000
Binary files a/src/seedsigner/resources/fonts/RobotoCondensed-Regular.ttf and /dev/null differ
diff --git a/src/seedsigner/resources/seedsigner-translations b/src/seedsigner/resources/seedsigner-translations
index 10ee02ae..708961a4 160000
--- a/src/seedsigner/resources/seedsigner-translations
+++ b/src/seedsigner/resources/seedsigner-translations
@@ -1 +1 @@
-Subproject commit 10ee02ae23156ab97600a407b2da66b84e89d29d
+Subproject commit 708961a4163b1bd43eb15c4a26713649c63f6ccd
diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py
index b39c9367..475aef49 100644
--- a/src/seedsigner/views/psbt_views.py
+++ b/src/seedsigner/views/psbt_views.py
@@ -22,11 +22,11 @@ class PSBTSelectSeedView(View):
# multisig where we want to sign with more than one key on this device.
if not self.controller.psbt:
# Shouldn't be able to get here
- raise Exception("No PSBT currently loaded")
+ raise Exception("No transaction currently loaded")
if self.controller.psbt_seed:
if PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=self.controller.psbt_seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
- # skip the seed prompt if a seed was previous selected and has matching input fingerprint
+ # skip the seed prompt if a seed was previously selected and has matching input fingerprint
return Destination(PSBTOverviewView)
seeds = self.controller.storage.seeds
@@ -166,11 +166,12 @@ class PSBTOverviewView(View):
class PSBTUnsupportedScriptTypeWarningView(View):
def run(self):
- selected_menu_num = WarningScreen(
+ selected_menu_num = self.run_screen(
+ WarningScreen,
status_headline=_("Unsupported Script Type!"),
- text=_("PSBT has unsupported input script type, please verify your change addresses."),
+ text=_("Transaction has unsupported input script type, please verify your change addresses."),
button_data=[ButtonOption("Continue")],
- ).display()
+ )
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -186,12 +187,13 @@ class PSBTUnsupportedScriptTypeWarningView(View):
class PSBTNoChangeWarningView(View):
def run(self):
- selected_menu_num = WarningScreen(
+ selected_menu_num = self.run_screen(
+ WarningScreen,
# TRANSLATOR_NOTE: User will receive no change back; the inputs to this transaction are fully spent
status_headline=_("Full Spend!"),
- text=_("This PSBT spends its entire input value. No change is coming back to your wallet."),
+ text=_("This transaction spends its entire input value. No change is coming back to your wallet."),
button_data=[ButtonOption("Continue")],
- ).display()
+ )
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -265,7 +267,7 @@ class PSBTAddressDetailsView(View):
button_data = []
if self.address_num < psbt_parser.num_destinations - 1:
- button_data.append(ButtonOption("Next Recipient"))
+ button_data.append(ButtonOption("Next recipient"))
else:
# TRANSLATOR_NOTE: Short for "Next step"
button_data.append(ButtonOption("Next"))
@@ -300,8 +302,8 @@ class PSBTAddressDetailsView(View):
class PSBTChangeDetailsView(View):
NEXT = ButtonOption("Next")
- SKIP_VERIFICATION = ButtonOption("Skip Verification")
- VERIFY_MULTISIG = ButtonOption("Verify Multisig Change")
+ SKIP_VERIFICATION = ButtonOption("Skip verification")
+ VERIFY_MULTISIG = ButtonOption("Verify multisig change")
def __init__(self, change_address_num):
super().__init__()
@@ -348,7 +350,7 @@ class PSBTChangeDetailsView(View):
title = _("Your Change")
else:
title = _("Self-Transfer")
- self.VERIFY_MULTISIG.button_label = _("Verify Multisig Addr")
+ self.VERIFY_MULTISIG.button_label = _("Verify multisig addr")
# if psbt_parser.num_change_outputs > 1:
# title += f" (#{self.change_address_num + 1})"
@@ -461,18 +463,19 @@ class PSBTAddressVerificationFailedView(View):
def run(self):
if self.is_multisig:
# TRANSLATOR_NOTE: Variable is either "change" or "self-transfer".
- text = _("PSBT's {} address could not be verified from wallet descriptor.").format(_("change") if self.is_change else _("self-transfer"))
+ text = _("Transaction's {} address could not be verified from wallet descriptor.").format(_("change") if self.is_change else _("self-transfer"))
else:
# TRANSLATOR_NOTE: Variable is either "change" or "self-transfer".
- text = _("PSBT's {} address could not be generated from your seed.").format(_("change") if self.is_change else _("self-transfer"))
+ text = _("Transaction's {} address could not be generated from your seed.").format(_("change") if self.is_change else _("self-transfer"))
- DireWarningScreen(
- title=_("Suspicious PSBT"),
+ self.run_screen(
+ DireWarningScreen,
+ title=_("Suspicious Transaction"),
status_headline=_("Address Verification Failed"),
text=text,
- button_data=[ButtonOption("Discard PSBT")],
+ button_data=[ButtonOption("Discard transaction")],
show_back_button=False,
- ).display()
+ )
# We're done with this PSBT. Route back to MainMenuView which always
# clears all ephemeral data (except in-memory seeds).
@@ -512,7 +515,7 @@ class PSBTOpReturnView(View):
class PSBTFinalizeView(View):
"""
"""
- APPROVE_PSBT = ButtonOption("Approve PSBT")
+ APPROVE_PSBT = ButtonOption("Approve transaction")
def run(self):
@@ -569,7 +572,7 @@ class PSBTSignedQRDisplayView(View):
class PSBTSigningErrorView(View):
- SELECT_DIFF_SEED = ButtonOption("Select Diff Seed")
+ SELECT_DIFF_SEED = ButtonOption("Select different seed")
def run(self):
psbt_parser: PSBTParser = self.controller.psbt_parser
@@ -580,7 +583,7 @@ class PSBTSigningErrorView(View):
# Just a WarningScreen here; only use DireWarningScreen for true security risks.
selected_menu_num = self.run_screen(
WarningScreen,
- title=_("PSBT Error"),
+ title=_("Transaction Error"),
status_icon_name=SeedSignerIconConstants.WARNING,
status_headline=_("Signing Failed"),
text=_("Signing with this seed did not add a valid signature."),
diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py
index 54097c3e..4f9d9b2d 100644
--- a/src/seedsigner/views/scan_views.py
+++ b/src/seedsigner/views/scan_views.py
@@ -171,8 +171,8 @@ class ScanView(View):
class ScanPSBTView(ScanView):
- instructions_text = _mft("Scan PSBT")
- invalid_qr_type_message = _mft("Expected a PSBT")
+ instructions_text = _mft("Scan Transaction")
+ invalid_qr_type_message = _mft("Expected a transaction")
@property
def is_valid_qr_type(self):
@@ -223,7 +223,7 @@ class ScanInvalidQRTypeView(View):
title=_("Error"),
status_headline=_("Unknown QR Type"),
text=_("QRCode is invalid or is a data format not yet supported."),
- button_data=[ButtonOption("Done")],
+ button_data=[ButtonOption("Back to Main Menu")],
)
return Destination(MainMenuView, clear_history=True)
diff --git a/src/seedsigner/views/screensaver.py b/src/seedsigner/views/screensaver.py
index d96f7f5d..8b2970da 100644
--- a/src/seedsigner/views/screensaver.py
+++ b/src/seedsigner/views/screensaver.py
@@ -43,21 +43,18 @@ class LogoScreen(BaseScreen):
@dataclass
class OpeningSplashView(View):
- is_screenshot_renderer: bool = False
force_partner_logos: bool|None = None
def run(self):
self.run_screen(
OpeningSplashScreen,
- is_screenshot_renderer=self.is_screenshot_renderer,
force_partner_logos=self.force_partner_logos
)
class OpeningSplashScreen(LogoScreen):
- def __init__(self, is_screenshot_renderer=False, force_partner_logos=None):
- self.is_screenshot_renderer = is_screenshot_renderer
+ def __init__(self, force_partner_logos=None):
self.force_partner_logos = force_partner_logos
super().__init__()
@@ -77,21 +74,26 @@ class OpeningSplashScreen(LogoScreen):
if self.force_partner_logos is not None:
show_partner_logos = self.force_partner_logos
+ logo_offset_x = int((self.canvas_width - self.logo.width)/2)
+
if show_partner_logos:
logo_offset_y = -56
else:
logo_offset_y = 0
background = Image.new("RGBA", size=self.logo.size, color="black")
- if not self.is_screenshot_renderer:
+ if not self.renderer.is_screenshot_generator:
# Fade in alpha
for i in range(250, -1, -25):
self.logo.putalpha(255 - i)
- self.renderer.canvas.paste(Image.alpha_composite(background, self.logo), (0, logo_offset_y))
+ self.renderer.canvas.paste(
+ Image.alpha_composite(background, self.logo),
+ (logo_offset_x, logo_offset_y)
+ )
self.renderer.show_image()
else:
# Skip animation for the screenshot generator
- self.renderer.canvas.paste(self.logo, (0, logo_offset_y))
+ self.renderer.canvas.paste(self.logo, (logo_offset_x, logo_offset_y))
# Display version num below SeedSigner logo
font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_top_nav_title_font_size())
@@ -103,11 +105,11 @@ class OpeningSplashScreen(LogoScreen):
version_y = int(self.canvas_height/2) + int(logo_height/2) + logo_offset_y + GUIConstants.COMPONENT_PADDING
self.renderer.draw.text(xy=(version_x, version_y), text=version, font=font, fill=GUIConstants.ACCENT_COLOR, anchor="mt")
- if not self.is_screenshot_renderer:
+ if not self.renderer.is_screenshot_generator:
self.renderer.show_image()
if show_partner_logos:
- if not self.is_screenshot_renderer:
+ if not self.renderer.is_screenshot_generator:
# Hold on the version num for a moment
time.sleep(1)
@@ -131,7 +133,7 @@ class OpeningSplashScreen(LogoScreen):
self.renderer.show_image()
- if not self.is_screenshot_renderer:
+ if not self.renderer.is_screenshot_generator:
# Hold on the splash screen for a moment
time.sleep(2)
@@ -144,17 +146,24 @@ class ScreensaverScreen(LogoScreen):
self.buttons = buttons
- # Paste the logo in a bigger image that is 2x the size of the logo
- self.image = Image.new("RGB", (2 * self.logo.size[0], 2 * self.logo.size[1]), (0,0,0))
- self.image.paste(self.logo, (int(self.logo.size[0] / 2), int(self.logo.size[1] / 2)))
+ # Paste the logo in a bigger image that is the canvas + the logo dims (half the
+ # logo will render off the canvas at each edge).
+ self.image = Image.new("RGB", (self.renderer.canvas_width + self.logo.width, self.renderer.canvas_height + self.logo.height), (0,0,0))
+
+ # Place the logo centered on the larger image
+ logo_x = int((self.image.width - self.logo.width) / 2)
+ logo_y = int((self.image.height - self.logo.height) / 2)
+ self.image.paste(self.logo, (logo_x, logo_y))
self.min_coords = (0, 0)
- self.max_coords = (self.logo.size[0], self.logo.size[1])
+ self.max_coords = (self.renderer.canvas_width, self.renderer.canvas_height)
+
+ # Update our first rendering position so we're centered
+ self.cur_x = int(self.logo.width / 2)
+ self.cur_y = int(self.logo.height / 2)
self.increment_x = self.rand_increment()
self.increment_y = self.rand_increment()
- self.cur_x = int(self.logo.size[0] / 2)
- self.cur_y = int(self.logo.size[1] / 2)
self._is_running = False
self.last_screen = None
@@ -197,7 +206,7 @@ class ScreensaverScreen(LogoScreen):
crop = self.image.crop((
self.cur_x, self.cur_y,
self.cur_x + self.renderer.canvas_width, self.cur_y + self.renderer.canvas_height))
- self.renderer.disp.ShowImage(crop, 0, 0)
+ self.renderer.disp.show_image(crop, 0, 0)
self.cur_x += self.increment_x
self.cur_y += self.increment_y
diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py
index 6ce7e163..65830d37 100644
--- a/src/seedsigner/views/seed_views.py
+++ b/src/seedsigner/views/seed_views.py
@@ -10,8 +10,8 @@ from embit.descriptor import Descriptor
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen,
WarningScreen, DireWarningScreen, seed_screens)
-from seedsigner.gui.screens.screen import ButtonOption
-from seedsigner.models.encode_qr import CompactSeedQrEncoder, GenericStaticQrEncoder, SeedQrEncoder, SpecterXPubQrEncoder, StaticXpubQrEncoder, UrXpubQrEncoder
+from seedsigner.gui.screens.screen import ButtonOption, ButtonOptionWithoutTranslation
+from seedsigner.models.encode_qr import CompactSeedQrEncoder, GenericStaticQrEncoder, SeedQrEncoder, SpecterLegacyXPubQrEncoder, StaticXpubQrEncoder, UrXpubQrEncoder
from seedsigner.models.qr_type import QRType
from seedsigner.models.seed import Seed
from seedsigner.models.settings import Settings, SettingsConstants
@@ -179,7 +179,7 @@ class LoadSeedView(View):
selected_menu_num = self.run_screen(
ButtonListScreen,
- title=_("Load A Seed"),
+ title=_("Load a Seed"),
is_button_text_centered=False,
button_data=button_data
)
@@ -226,11 +226,13 @@ class SeedMnemonicEntryView(View):
)
if ret == RET_CODE__BACK_BUTTON:
- if self.cur_word_index > 0:
- return Destination(BackStackView)
- else:
+ # This handles two possible scenarios:
+ # 1. Backing out of the first word cancels the mnemonic entry process;
+ # return to whichever `View` routed us here initially.
+ # 2. Backing out of the current word returns to the previous word.
+ if self.cur_word_index == 0:
self.controller.storage.discard_pending_mnemonic()
- return Destination(MainMenuView)
+ return Destination(BackStackView)
# ret will be our new mnemonic word
self.controller.storage.update_pending_mnemonic(ret, self.cur_word_index)
@@ -268,7 +270,7 @@ class SeedMnemonicEntryView(View):
class SeedMnemonicInvalidView(View):
- EDIT = ButtonOption("Review & Edit")
+ EDIT = ButtonOption("Review & edit")
DISCARD = ButtonOption("Discard", button_label_color="red")
def __init__(self):
@@ -339,9 +341,6 @@ class SeedFinalizeView(View):
elif button_data[selected_menu_num] == self.PASSPHRASE:
return Destination(SeedAddPassphraseView)
- elif selected_menu_num == RET_CODE__BACK_BUTTON:
- return Destination(BackStackView)
-
class SeedAddPassphraseView(View):
@@ -366,23 +365,18 @@ class SeedAddPassphraseView(View):
# The new passphrase will be the return value; it might be empty.
self.seed.set_passphrase(ret_dict["passphrase"])
- if "is_back_button" in ret_dict:
- if len(self.seed.passphrase) > 0:
- return Destination(SeedAddPassphraseExitDialogView)
- else:
- return Destination(BackStackView)
-
- elif len(self.seed.passphrase) > 0:
- return Destination(SeedReviewPassphraseView)
-
+ if "is_back_button" in ret_dict or len(self.seed.passphrase) == 0:
+ return Destination(SeedAddPassphraseExitDialogView)
+
else:
- return Destination(SeedFinalizeView)
+ return Destination(SeedReviewPassphraseView)
class SeedAddPassphraseExitDialogView(View):
EDIT = ButtonOption("Edit passphrase")
DISCARD = ButtonOption("Discard passphrase", button_label_color="red")
+ SKIP = ButtonOption("Skip passphrase") # NOT red since we're not throwing anything away
def __init__(self):
super().__init__()
@@ -390,13 +384,20 @@ class SeedAddPassphraseExitDialogView(View):
def run(self):
- button_data = [self.EDIT, self.DISCARD]
+ if self.seed.passphrase:
+ title = _("Discard passphrase?")
+ message = _("Your current passphrase entry will be erased.")
+ button_data = [self.EDIT, self.DISCARD]
+ else:
+ title = _("Skip passphrase?")
+ message = _("You have not entered a passphrase yet.")
+ button_data = [self.EDIT, self.SKIP]
selected_menu_num = self.run_screen(
WarningScreen,
- title=_("Discard passphrase?"),
+ title=title,
status_headline=None,
- text=_("Your current passphrase entry will be erased"),
+ text=message,
show_back_button=False,
button_data=button_data,
)
@@ -404,7 +405,7 @@ class SeedAddPassphraseExitDialogView(View):
if button_data[selected_menu_num] == self.EDIT:
return Destination(SeedAddPassphraseView)
- elif button_data[selected_menu_num] == self.DISCARD:
+ elif button_data[selected_menu_num] in [self.DISCARD, self.SKIP]:
self.seed.set_passphrase("")
return Destination(SeedFinalizeView)
@@ -454,7 +455,7 @@ class SeedReviewPassphraseView(View):
class SeedDiscardView(View):
- KEEP = ButtonOption("Keep Seed")
+ KEEP = ButtonOption("Keep seed")
DISCARD = ButtonOption("Discard", button_label_color="red")
def __init__(self, seed_num: int = None):
@@ -506,7 +507,7 @@ class SeedElectrumMnemonicStartView(View):
def run(self):
self.run_screen(
WarningScreen,
- title=_("Electrum warning"),
+ title=_("Electrum Warning"),
status_headline=None,
text=_("Some features are disabled for Electrum seeds."),
show_back_button=False,
@@ -522,14 +523,13 @@ class SeedElectrumMnemonicStartView(View):
Views for actions on individual seeds:
****************************************************************************"""
class SeedOptionsView(View):
- SCAN_PSBT = ButtonOption("Scan PSBT", SeedSignerIconConstants.QRCODE)
- VERIFY_ADDRESS = ButtonOption("Verify Addr")
- EXPORT_XPUB = ButtonOption("Export Xpub")
- EXPLORER = ButtonOption("Address Explorer")
- SIGN_MESSAGE = ButtonOption("Sign Message")
- BACKUP = ButtonOption("Backup Seed", right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT)
- BIP85_CHILD_SEED = ButtonOption("BIP-85 Child Seed")
- DISCARD = ButtonOption("Discard Seed", button_label_color="red")
+ SCAN_PSBT = ButtonOption("Scan transaction", SeedSignerIconConstants.QRCODE)
+ EXPORT_XPUB = ButtonOption("Export xpub")
+ EXPLORER = ButtonOption("Address explorer")
+ SIGN_MESSAGE = ButtonOption("Sign message")
+ BACKUP = ButtonOption("Backup seed", right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT)
+ BIP85_CHILD_SEED = ButtonOption("BIP-85 child seed")
+ DISCARD = ButtonOption("Discard seed", button_label_color="red")
def __init__(self, seed_num: int):
@@ -569,16 +569,9 @@ class SeedOptionsView(View):
button_data = []
- if self.controller.unverified_address:
- # TODO: Verify that an addr verification flow can actually reach this code
- addr = self.controller.unverified_address["address"][:7]
- self.VERIFY_ADDRESS.button_label += f" {addr}"
- button_data.append(self.VERIFY_ADDRESS)
-
button_data.append(self.SCAN_PSBT)
- if self.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED:
- button_data.append(self.EXPORT_XPUB)
+ button_data.append(self.EXPORT_XPUB)
button_data.append(self.EXPLORER)
button_data.append(self.BACKUP)
@@ -595,7 +588,6 @@ class SeedOptionsView(View):
seed_screens.SeedOptionsScreen,
button_data=button_data,
fingerprint=self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)),
- has_passphrase=self.seed.passphrase is not None,
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
@@ -607,9 +599,6 @@ class SeedOptionsView(View):
self.controller.psbt_seed = self.controller.get_seed(self.seed_num)
return Destination(ScanPSBTView)
- elif button_data[selected_menu_num] == self.VERIFY_ADDRESS:
- return Destination(SeedAddressVerificationView, view_args=dict(seed_num=self.seed_num))
-
elif button_data[selected_menu_num] == self.EXPORT_XPUB:
return Destination(SeedExportXpubSigTypeView, view_args=dict(seed_num=self.seed_num))
@@ -627,7 +616,7 @@ class SeedOptionsView(View):
return Destination(SeedBackupView, view_args=dict(seed_num=self.seed_num))
elif button_data[selected_menu_num] == self.BIP85_CHILD_SEED:
- return Destination(SeedBIP85ApplicationModeView, view_args={"seed_num": self.seed_num})
+ return Destination(SeedBIP85SelectNumWordsView, view_args={"seed_num": self.seed_num})
elif button_data[selected_menu_num] == self.DISCARD:
return Destination(SeedDiscardView, view_args=dict(seed_num=self.seed_num))
@@ -635,7 +624,7 @@ class SeedOptionsView(View):
class SeedBackupView(View):
- VIEW_WORDS = ButtonOption("View Seed Words")
+ VIEW_WORDS = ButtonOption("View seed words")
EXPORT_SEEDQR = ButtonOption("Export as SeedQR")
def __init__(self, seed_num):
@@ -729,7 +718,7 @@ class SeedExportXpubScriptTypeView(View):
del args["sig_type"]
return Destination(ToolsAddressExplorerAddressTypeView, view_args=args, skip_current_view=True)
else:
- return Destination(SeedExportXpubCoordinatorView, view_args=args, skip_current_view=True)
+ return Destination(SeedExportXpubQRFormatView, view_args=args, skip_current_view=True)
title = _("Export Xpub")
if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER:
@@ -764,7 +753,7 @@ class SeedExportXpubScriptTypeView(View):
del args["sig_type"]
return Destination(ToolsAddressExplorerAddressTypeView, view_args=args)
else:
- return Destination(SeedExportXpubCoordinatorView, view_args=args)
+ return Destination(SeedExportXpubQRFormatView, view_args=args)
@@ -795,7 +784,7 @@ class SeedExportXpubCustomDerivationView(View):
return Destination(ToolsAddressExplorerAddressTypeView, view_args=dict(seed_num=self.seed_num, script_type=self.script_type, custom_derivation=custom_derivation))
return Destination(
- SeedExportXpubCoordinatorView,
+ SeedExportXpubQRFormatView,
view_args={
"seed_num": self.seed_num,
"sig_type": self.sig_type,
@@ -806,7 +795,7 @@ class SeedExportXpubCustomDerivationView(View):
-class SeedExportXpubCoordinatorView(View):
+class SeedExportXpubQRFormatView(View):
def __init__(self, seed_num: int, sig_type: str, script_type: str, custom_derivation: str = None):
super().__init__()
self.seed_num = seed_num
@@ -822,41 +811,39 @@ class SeedExportXpubCoordinatorView(View):
"script_type": self.script_type,
"custom_derivation": self.custom_derivation,
}
- if len(self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)) == 1:
+ if len(self.settings.get_value(SettingsConstants.SETTING__XPUB_QR_FORMAT)) == 1:
# Nothing to select; skip this screen
- args["coordinator"] = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[0]
+ args["xpub_qr_format"] = self.settings.get_value(SettingsConstants.SETTING__XPUB_QR_FORMAT)[0]
return Destination(SeedExportXpubWarningView, view_args=args, skip_current_view=True)
button_data = []
- for display_name, setting_option in zip(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS), self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)):
+ for display_name, setting_option in zip(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__XPUB_QR_FORMAT), self.settings.get_value(SettingsConstants.SETTING__XPUB_QR_FORMAT)):
button_data.append(ButtonOption(display_name, return_data=setting_option))
selected_menu_num = self.run_screen(
ButtonListScreen,
- title=_("Export Xpub"),
+ title=_("Xpub QR Format"),
is_button_text_centered=False,
button_data=button_data,
+ is_bottom_list=True,
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
- # coordinators_settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS)
- # selected_display_name = button_data[selected_menu_num]
- # args["coordinator"] = coordinators_settings_entry.get_selection_option_value_by_display_name(selected_display_name)
- args["coordinator"] = button_data[selected_menu_num].return_data
+ args["xpub_qr_format"] = button_data[selected_menu_num].return_data
return Destination(SeedExportXpubWarningView, view_args=args)
class SeedExportXpubWarningView(View):
- def __init__(self, seed_num: int, sig_type: str, script_type: str, coordinator: str, custom_derivation: str):
+ def __init__(self, seed_num: int, sig_type: str, script_type: str, xpub_qr_format: str, custom_derivation: str):
super().__init__()
self.seed_num = seed_num
self.sig_type = sig_type
self.script_type = script_type
- self.coordinator = coordinator
+ self.xpub_qr_format = xpub_qr_format
self.custom_derivation = custom_derivation
@@ -867,7 +854,7 @@ class SeedExportXpubWarningView(View):
"seed_num": self.seed_num,
"sig_type": self.sig_type,
"script_type": self.script_type,
- "coordinator": self.coordinator,
+ "xpub_qr_format": self.xpub_qr_format,
"custom_derivation": self.custom_derivation,
},
skip_current_view=True, # Prevent going BACK to WarningViews
@@ -897,11 +884,11 @@ class SeedExportXpubDetailsView(View):
Collects the user input from all the previous screens leading up to this and
finally calculates the xpub and displays the summary view to the user.
"""
- def __init__(self, seed_num: int, sig_type: str, script_type: str, coordinator: str, custom_derivation: str):
+ def __init__(self, seed_num: int, sig_type: str, script_type: str, xpub_qr_format: str, custom_derivation: str):
super().__init__()
self.sig_type = sig_type
self.script_type = script_type
- self.coordinator = coordinator
+ self.xpub_qr_format = xpub_qr_format
self.custom_derivation = custom_derivation
self.seed_num = seed_num
@@ -956,7 +943,6 @@ class SeedExportXpubDetailsView(View):
selected_menu_num = self.run_screen(
seed_screens.SeedExportXpubDetailsScreen,
fingerprint=fingerprint,
- has_passphrase=self.seed.passphrase is not None,
derivation_path=derivation_path,
xpub=xpub_base58,
)
@@ -965,7 +951,7 @@ class SeedExportXpubDetailsView(View):
return Destination(
SeedExportXpubQRDisplayView,
dict(seed_num=self.seed_num,
- coordinator=self.coordinator,
+ xpub_qr_format=self.xpub_qr_format,
derivation_path=derivation_path,
sig_type=self.sig_type
)
@@ -977,7 +963,7 @@ class SeedExportXpubDetailsView(View):
class SeedExportXpubQRDisplayView(View):
- def __init__(self, seed_num: int, coordinator: str, derivation_path: str, sig_type: str = SettingsConstants.SINGLE_SIG):
+ def __init__(self, seed_num: int, xpub_qr_format: str, derivation_path: str, sig_type: str = SettingsConstants.SINGLE_SIG):
super().__init__()
self.seed = self.controller.get_seed(seed_num)
@@ -989,14 +975,14 @@ class SeedExportXpubQRDisplayView(View):
sig_type=sig_type
)
- if coordinator == SettingsConstants.COORDINATOR__SPECTER_DESKTOP:
- self.qr_encoder = SpecterXPubQrEncoder(**encoder_args)
-
- elif coordinator in [SettingsConstants.COORDINATOR__BLUE_WALLET,
- SettingsConstants.COORDINATOR__KEEPER]:
+ if xpub_qr_format == SettingsConstants.XPUB_QR_FORMAT__STATIC:
self.qr_encoder = StaticXpubQrEncoder(**encoder_args)
+ elif xpub_qr_format == SettingsConstants.XPUB_QR_FORMAT__SPECTER_LEGACY:
+ self.qr_encoder = SpecterLegacyXPubQrEncoder(**encoder_args)
+
else:
+ # Default: UR crypto-address
self.qr_encoder = UrXpubQrEncoder(**encoder_args)
@@ -1084,13 +1070,14 @@ class SeedWordsView(View):
else:
button_data.append(self.DONE)
- selected_menu_num = seed_screens.SeedWordsScreen(
+ selected_menu_num = self.run_screen(
+ seed_screens.SeedWordsScreen,
title=f"{title}: {self.page_index+1}/{num_pages}",
words=words,
page_index=self.page_index,
num_pages=num_pages,
button_data=button_data,
- ).display()
+ )
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -1117,17 +1104,9 @@ class SeedWordsView(View):
"""****************************************************************************
- BIP85 - Derive child mnemonic (seed) flow
+ BIP-85 - Derive child mnemonic (seed) flow (Application number 39')
****************************************************************************"""
-class SeedBIP85ApplicationModeView(View):
- """
- * Ask the user the application type as defined in the BIP0085 spec.
- * Currently only Word mode of 12, 24 words (Application number: 39')
- * Possible future additions are
- * WIF (HDSEED)
- * XPRV (BIP32)
- """
- # TODO: Future enhancement to display WIF (HD-SEED) and XPRV (Bip32)?
+class SeedBIP85SelectNumWordsView(View):
WORDS_12 = ButtonOption("12 Words")
WORDS_24 = ButtonOption("24 Words")
@@ -1135,16 +1114,16 @@ class SeedBIP85ApplicationModeView(View):
super().__init__()
self.seed_num = seed_num
self.num_words = 0
- self.bip85_app_num = 39 # TODO: Support other Application numbers; TODO: Define this as a constant
def run(self):
button_data = [self.WORDS_12, self.WORDS_24]
- selected_menu_num = ButtonListScreen(
+ selected_menu_num = self.run_screen(
+ ButtonListScreen,
title=_("BIP-85 Num Words"),
button_data=button_data
- ).display()
+ )
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -1171,7 +1150,7 @@ class SeedBIP85SelectChildIndexView(View):
def run(self):
# TODO: Change this later to use the generic Screen input keyboard
- ret = seed_screens.SeedBIP85SelectChildIndexScreen().display()
+ ret = self.run_screen(seed_screens.SeedBIP85SelectChildIndexScreen)
if ret == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -1204,14 +1183,15 @@ class SeedBIP85InvalidChildIndexView(View):
def run(self):
- DireWarningScreen(
+ self.run_screen(
+ DireWarningScreen,
title=_("BIP-85 Index Error"),
show_back_button=False,
status_icon_name=SeedSignerIconConstants.ERROR,
status_headline=_("Invalid Child Index"),
text=_("BIP-85 Child Index must be between 0 and 2^31-1."),
- button_data=[ButtonOption("Try Again")]
- ).display()
+ button_data=[ButtonOption("Try again")]
+ )
return Destination(
SeedBIP85SelectChildIndexView,
@@ -1239,9 +1219,10 @@ class SeedWordsBackupTestPromptView(View):
def run(self):
button_data = [self.VERIFY, self.SKIP]
- selected_menu_num = seed_screens.SeedWordsBackupTestPromptScreen(
+ selected_menu_num = self.run_screen(
+ seed_screens.SeedWordsBackupTestPromptScreen,
button_data=button_data,
- ).display()
+ )
if button_data[selected_menu_num] == self.VERIFY:
return Destination(
@@ -1295,23 +1276,24 @@ class SeedWordsBackupTestView(View):
while self.cur_index in self.confirmed_list:
self.cur_index = int(random.random() * len(self.mnemonic_list))
- real_word = ButtonOption(self.mnemonic_list[self.cur_index])
- fake_word1 = ButtonOption(bip39.WORDLIST[int(random.random() * 2047)])
- fake_word2 = ButtonOption(bip39.WORDLIST[int(random.random() * 2047)])
- fake_word3 = ButtonOption(bip39.WORDLIST[int(random.random() * 2047)])
+ real_word = ButtonOptionWithoutTranslation(self.mnemonic_list[self.cur_index])
+ fake_word1 = ButtonOptionWithoutTranslation(bip39.WORDLIST[int(random.random() * 2047)])
+ fake_word2 = ButtonOptionWithoutTranslation(bip39.WORDLIST[int(random.random() * 2047)])
+ fake_word3 = ButtonOptionWithoutTranslation(bip39.WORDLIST[int(random.random() * 2047)])
button_data = [real_word, fake_word1, fake_word2, fake_word3]
random.shuffle(button_data)
# TRANSLATOR_NOTE: Inserts the word number (e.g. "Verify Word #1")
title = _("Verify Word #{}").format(self.cur_index + 1)
- selected_menu_num = ButtonListScreen(
+ selected_menu_num = self.run_screen(
+ ButtonListScreen,
title=title,
show_back_button=False,
button_data=button_data,
is_bottom_list=True,
is_button_text_centered=True,
- ).display()
+ )
if button_data[selected_menu_num] == real_word:
self.confirmed_list.append(self.cur_index)
@@ -1344,8 +1326,8 @@ class SeedWordsBackupTestView(View):
class SeedWordsBackupTestMistakeView(View):
- REVIEW = ButtonOption("Review Seed Words")
- RETRY = ButtonOption("Try Again")
+ REVIEW = ButtonOption("Review seed words")
+ RETRY = ButtonOption("Try again")
def __init__(self, seed_num: int, bip85_data: dict = None, cur_index: int = None, wrong_word: str = None, confirmed_list: list[bool] = None):
super().__init__()
@@ -1365,14 +1347,15 @@ class SeedWordsBackupTestMistakeView(View):
# TRANSLATOR_NOTE: User selected the wrong word during the mnemonic backup test (e.g. incorrectly said the 5th word was "zoo")
status_headline = _("Wrong Word!")
- selected_menu_num = DireWarningScreen(
+ selected_menu_num = self.run_screen(
+ DireWarningScreen,
title=_("Verification Error"),
show_back_button=False,
status_icon_name=SeedSignerIconConstants.ERROR,
status_headline=status_headline,
button_data=button_data,
text=text,
- ).display()
+ )
if button_data[selected_menu_num] == self.REVIEW:
return Destination(
@@ -1400,13 +1383,14 @@ class SeedWordsBackupTestSuccessView(View):
def run(self):
from seedsigner.gui.screens.screen import LargeIconStatusScreen
- LargeIconStatusScreen(
+ self.run_screen(
+ LargeIconStatusScreen,
title=_("Backup Verified"),
show_back_button=False,
status_headline=_("Success!"),
text=_("All mnemonic backup words were successfully verified!"),
button_data=[ButtonOption("OK")]
- ).display()
+ )
if self.seed_num is not None:
return Destination(SeedOptionsView, view_args=dict(seed_num=self.seed_num), clear_history=True)
@@ -1558,16 +1542,17 @@ class SeedTranscribeSeedQRWholeQRView(View):
class SeedTranscribeSeedQRZoomedInView(View):
"""
- intial_block_x, initial_block_y: Used by the screenshot generator to shift the view
+ intial_zone_x, initial_zone_y: Used by the screenshot generator to shift the view
to a more interesting part of the QR code template.
"""
- def __init__(self, seed_num: int, seedqr_format: str, initial_block_x: int = 0, initial_block_y: int = 0):
+ def __init__(self, seed_num: int, seedqr_format: str, initial_zone_x: int = 0, initial_zone_y: int = 0):
super().__init__()
self.seed_num = seed_num
self.seedqr_format = seedqr_format
self.seed = self.controller.get_seed(seed_num)
- self.initial_block_x = initial_block_x
- self.initial_block_y = initial_block_y
+ self.initial_zone_x = initial_zone_x
+ self.initial_zone_y = initial_zone_y
+ self.is_screensaver_allowed = False
def run(self):
@@ -1591,12 +1576,13 @@ class SeedTranscribeSeedQRZoomedInView(View):
else:
num_modules = 25
- seed_screens.SeedTranscribeSeedQRZoomedInScreen(
+ self.run_screen(
+ seed_screens.SeedTranscribeSeedQRZoomedInScreen,
qr_data=data,
num_modules=num_modules,
- initial_block_x=self.initial_block_x,
- initial_block_y=self.initial_block_y,
- ).display()
+ initial_zone_x=self.initial_zone_x,
+ initial_zone_y=self.initial_zone_y,
+ )
return Destination(SeedTranscribeSeedQRConfirmQRPromptView, view_args={"seed_num": self.seed_num})
@@ -1646,12 +1632,16 @@ class SeedTranscribeSeedQRConfirmScanView(View):
# Run the live preview and QR code capture process
# TODO: Does this belong in its own BaseThread?
- self.run_screen(
+ scanning_done=self.run_screen(
ScanScreen,
decoder=self.decoder,
instructions_text=_("Scan your SeedQR")
)
+ # If the scanning was canceled because the back button was pressed, return to BackStackView (SeedTranscribeSeedQRConfirmQRPromptView).
+ if scanning_done==False:
+ return Destination(BackStackView, skip_current_view=False)
+
if self.decoder.is_complete:
if self.decoder.is_seed:
seed_mnemonic = self.decoder.get_seed_phrase()
@@ -1661,9 +1651,8 @@ class SeedTranscribeSeedQRConfirmScanView(View):
else:
return Destination(SeedTranscribeSeedQRConfirmSuccessView, view_args={"seed_num": self.seed_num})
- else:
- # Will this case ever happen? Will trigger if a different kind of QR code is scanned
- return Destination(SeedTranscribeSeedQRConfirmInvalidQRView, skip_current_view=True)
+ # Will trigger if a different kind of QR code is scanned (non SeedQR)
+ return Destination(SeedTranscribeSeedQRConfirmInvalidQRView, skip_current_view=True)
@@ -1855,7 +1844,8 @@ class SeedAddressVerificationView(View):
self.seed_derivation_override = ""
if not self.is_multisig:
if seed_num is None:
- raise Exception(_("Can't validate a single sig addr without specifying a seed"))
+ # Shouldn't be able to get here
+ raise Exception("Can't validate a single sig addr without specifying a seed")
self.seed_num = seed_num
self.seed = self.controller.get_seed(seed_num)
self.seed_derivation_override = self.seed.derivation_override(sig_type=SettingsConstants.SINGLE_SIG)
@@ -2039,7 +2029,7 @@ class SeedAddressVerificationSuccessView(View):
class LoadMultisigWalletDescriptorView(View):
- SCAN = ButtonOption("Scan Descriptor", SeedSignerIconConstants.QRCODE)
+ SCAN = ButtonOption("Scan descriptor", SeedSignerIconConstants.QRCODE)
CANCEL = ButtonOption("Cancel")
def run(self):
@@ -2064,9 +2054,9 @@ class LoadMultisigWalletDescriptorView(View):
class MultisigWalletDescriptorView(View):
- RETURN = ButtonOption("Return to PSBT")
- VERIFY_ADDR = ButtonOption("Verify Addr")
- ADDRESS_EXPLORER = ButtonOption("Address Explorer")
+ RETURN = ButtonOption("Return to transaction")
+ VERIFY_ADDR = ButtonOption("Verify addr")
+ ADDRESS_EXPLORER = ButtonOption("Address explorer")
OK = ButtonOption("OK")
def run(self):
@@ -2077,8 +2067,10 @@ class MultisigWalletDescriptorView(View):
fingerprint = hexlify(key.fingerprint).decode()
fingerprints.append(fingerprint)
- policy = descriptor.brief_policy.split("multisig")[0].strip()
- # policy = " / ".join(policy.split(" of ")) # i18n w/o l10n since coming from non-l10n embit
+ from seedsigner.helpers.embit_utils import get_multisig_policy
+ threshold, n = get_multisig_policy(descriptor)
+ # TRANSLATOR_NOTE: Multisig policy. For a "2 of 3" policy, "threshold" = 2; "n" = 3
+ policy = _("{threshold} of {n}").format(threshold=threshold, n=n)
button_data = [self.OK]
if self.controller.resume_main_flow:
diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py
index b4fb86a6..56141c20 100644
--- a/src/seedsigner/views/settings_views.py
+++ b/src/seedsigner/views/settings_views.py
@@ -1,7 +1,7 @@
import logging
from gettext import gettext as _
-from seedsigner.gui.components import SeedSignerIconConstants
+from seedsigner.gui.components import GUIConstants, SeedSignerIconConstants
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, settings_screens)
from seedsigner.gui.screens.screen import ButtonOption
from seedsigner.models.settings import Settings, SettingsConstants, SettingsDefinition
@@ -13,6 +13,8 @@ logger = logging.getLogger(__name__)
class SettingsMenuView(View):
+ ADVANCED = ButtonOption("Advanced", right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT)
+ HARDWARE = ButtonOption("Hardware", right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT)
IO_TEST = ButtonOption("I/O test")
DONATE = ButtonOption("Donate")
@@ -29,7 +31,7 @@ class SettingsMenuView(View):
settings_entries = SettingsDefinition.get_settings_entries(
visibility=self.visibility
)
- button_data=[ButtonOption(e.display_name) for e in settings_entries]
+ button_data: list[ButtonOption] = [ButtonOption(e.display_name) for e in settings_entries]
selected_button = 0
if self.selected_attr:
@@ -42,7 +44,7 @@ class SettingsMenuView(View):
title = _("Settings")
# Set up the next nested level of menuing
- button_data.append(ButtonOption("Advanced", right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT))
+ button_data.append(self.ADVANCED)
next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED})
button_data.append(self.IO_TEST)
@@ -51,11 +53,14 @@ class SettingsMenuView(View):
elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED:
title = _("Advanced")
- # So far there are no real Developer options; disabling for now
- # button_data.append(("Developer Options", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT))
- # next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER})
+ # The hardware options nest below "Advanced"
+ button_data.append(self.HARDWARE)
+ next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__HARDWARE})
+
+ elif self.visibility == SettingsConstants.VISIBILITY__HARDWARE:
+ title = "Hardware"
next_destination = None
-
+
elif self.visibility == SettingsConstants.VISIBILITY__DEVELOPER:
title = _("Dev Options")
next_destination = None
@@ -80,30 +85,80 @@ class SettingsMenuView(View):
else:
return Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED})
- elif selected_menu_num == len(settings_entries):
+ if button_data[selected_menu_num] == self.ADVANCED:
return next_destination
- elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == self.IO_TEST:
+ elif button_data[selected_menu_num] == self.HARDWARE:
+ return next_destination
+
+ elif button_data[selected_menu_num] == self.IO_TEST:
return Destination(IOTestView)
- elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == self.DONATE:
+ elif button_data[selected_menu_num] == self.DONATE:
return Destination(DonateView)
+ elif settings_entries[selected_menu_num].attr_name == SettingsConstants.SETTING__LOCALE:
+ return Destination(LocaleSelectionView)
+
else:
return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=settings_entries[selected_menu_num].attr_name, parent_initial_scroll=initial_scroll))
+class LocaleSelectionView(View):
+ def run(self):
+ cur_language_code = self.settings.get_value(SettingsConstants.SETTING__LOCALE)
+
+ selected_button = 0
+ button_data: list[ButtonOption] = []
+ for i, (language_code, display_name) in enumerate(SettingsConstants.get_detected_languages()):
+ button_data.append(
+ # Unique to this View: override each button's font so we can display each
+ # language name in its native script.
+ ButtonOption(
+ button_label=display_name,
+ return_data=language_code,
+ font_name=GUIConstants.get_button_font_name(language_code),
+ font_size=GUIConstants.get_button_font_size(language_code),
+ )
+ )
+
+ if language_code == cur_language_code:
+ # Highlight the current selection
+ selected_button = i
+
+ selected_menu_num = self.run_screen(
+ settings_screens.SettingsEntryUpdateSelectionScreen,
+ display_name=_(SettingsDefinition.get_settings_entry(attr_name=SettingsConstants.SETTING__LOCALE).display_name),
+ button_data=button_data,
+ selected_button=selected_button,
+ checked_buttons=[selected_button],
+ )
+
+ if selected_menu_num == RET_CODE__BACK_BUTTON:
+ return Destination(SettingsMenuView)
+
+ # Set the new language
+ self.settings.set_value(SettingsConstants.SETTING__LOCALE, button_data[selected_menu_num].return_data)
+
+ return Destination(SettingsMenuView)
+
+
+
class SettingsEntryUpdateSelectionView(View):
"""
Handles changes to all selection-type settings (Multiselect, SELECT_1,
Enabled/Disabled, etc).
"""
- def __init__(self, attr_name: str, parent_initial_scroll: int = 0, selected_button: int = None):
+ def __init__(self, attr_name: str, parent_initial_scroll: int = 0, selected_button: int = None, blocking_view: View = None, unblocking_view: View = None):
super().__init__()
self.settings_entry = SettingsDefinition.get_settings_entry(attr_name)
self.selected_button = selected_button
self.parent_initial_scroll = parent_initial_scroll
+ # If the setting remains unchanged, navigation should return to blocking_view (if set)
+ self.blocking_view = blocking_view
+ # unblocking_view is an optional target to navigate to once the setting actually changes.
+ self.unblocking_view = unblocking_view
def run(self):
@@ -149,6 +204,15 @@ class SettingsEntryUpdateSelectionView(View):
)
if ret_value == RET_CODE__BACK_BUTTON:
+ if self.settings_entry.type == SettingsConstants.TYPE__MULTISELECT:
+ # After the user finishes toggling multiselect options, initial_value will
+ # have their final selections when they hit BACK to exit. All current
+ # multiselect settings require at least one option to be selected.
+ if not initial_value:
+ return Destination(SettingsSelectionRequiredWarningView, view_args={"attr_name": self.settings_entry.attr_name})
+
+ if self.blocking_view:
+ return Destination(self.blocking_view, clear_history=True)
return settings_menu_view_destination
value = self.settings_entry.get_selection_option_value(ret_value)
@@ -168,8 +232,7 @@ class SettingsEntryUpdateSelectionView(View):
else:
# All other types are single selects (e.g. Enabled/Disabled, SELECT_1)
- if value == initial_value:
- # No change, return to menu
+ if value == initial_value and not self.blocking_view:
return settings_menu_view_destination
else:
updated_value = value
@@ -179,13 +242,60 @@ class SettingsEntryUpdateSelectionView(View):
value=updated_value
)
+ if self.settings_entry.attr_name == SettingsConstants.SETTING__DISPLAY_CONFIGURATION:
+ self.renderer.initialize_display()
+
+ elif self.settings_entry.attr_name == SettingsConstants.SETTING__DISPLAY_COLOR_INVERTED:
+ self.renderer.disp.invert(enabled=updated_value == SettingsConstants.OPTION__ENABLED)
+
if destination:
return destination
+
+ # If this selection view was opened from a blocking flow (e.g. RemoveMicroSDWarningView),
+ # prevent navigation away until the setting actually changes. If it hasn't changed,
+ # return to the blocking view so it can re-evaluate the state.
+ if self.blocking_view:
+ current_value = self.settings.get_value(self.settings_entry.attr_name)
+ if current_value == initial_value:
+ return Destination(self.blocking_view, clear_history=True)
+ elif self.unblocking_view:
+ return Destination(self.unblocking_view, clear_history=True)
# All selects stay in place; re-initialize where in the list we left off
self.selected_button = ret_value
- return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_entry.attr_name, parent_initial_scroll=self.parent_initial_scroll, selected_button=self.selected_button), skip_current_view=True)
+ return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_entry.attr_name, parent_initial_scroll=self.parent_initial_scroll, selected_button=self.selected_button, blocking_view=self.blocking_view, unblocking_view=self.unblocking_view), skip_current_view=True)
+
+
+
+class SettingsSelectionRequiredWarningView(View):
+ def __init__(self, attr_name: str):
+ super().__init__()
+ self.settings_entry = SettingsDefinition.get_settings_entry(attr_name)
+
+
+ def run(self):
+ from seedsigner.gui.screens.screen import WarningScreen
+
+ # TRANSLATOR_NOTE: Title of a warning dialog when configuring a setting that requires at least one option to be selected.
+ title = _("Selection Required")
+
+ # TRANSLATOR_NOTE: The name of the setting being configured (e.g. "Script types") will be inserted.
+ text = _("At least one option must be selected for \"{}\".").format(self.settings_entry.display_name)
+
+ # TRANSLATOR_NOTE: Text for the button that returns the user to the setting configuration screen.
+ button_text = _("Return to setting")
+
+ self.run_screen(
+ WarningScreen,
+ title=title,
+ status_headline=None,
+ text=text,
+ button_data=[ButtonOption(button_text)],
+ show_back_button=False,
+ )
+
+ return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_entry.attr_name))
@@ -197,9 +307,16 @@ class SettingsIngestSettingsQRView(View):
# May raise an Exception which will bubble up to the Controller to display to the
# user.
self.config_name, settings_update_dict = Settings.parse_settingsqr(data)
+
+ changes_display_driver = (
+ SettingsConstants.SETTING__DISPLAY_CONFIGURATION in settings_update_dict and
+ self.settings.get_value(SettingsConstants.SETTING__DISPLAY_CONFIGURATION) != settings_update_dict[SettingsConstants.SETTING__DISPLAY_CONFIGURATION])
self.settings.update(settings_update_dict)
+ if changes_display_driver:
+ self.renderer.initialize_display()
+
if MicroSD.get_instance().is_inserted and self.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__ENABLED:
self.status_message = _("Persistent Settings enabled. Settings saved to SD card.")
else:
diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py
index a6828fe7..798cf5ba 100644
--- a/src/seedsigner/views/tools_views.py
+++ b/src/seedsigner/views/tools_views.py
@@ -1,11 +1,10 @@
import hashlib
import logging
-import os
import time
from gettext import gettext as _
-from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerIconConstants
+from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerIconConstants, resize_image_to_fill
from seedsigner.gui.screens import RET_CODE__BACK_BUTTON, ButtonListScreen
from seedsigner.gui.screens.screen import ButtonOption
from seedsigner.helpers import mnemonic_generation
@@ -23,8 +22,8 @@ class ToolsMenuView(View):
IMAGE = ButtonOption("New seed", FontAwesomeIconConstants.CAMERA)
DICE = ButtonOption("New seed", FontAwesomeIconConstants.DICE)
KEYBOARD = ButtonOption("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD)
- ADDRESS_EXPLORER = ButtonOption("Address Explorer")
- VERIFY_ADDRESS = ButtonOption("Verify Address")
+ ADDRESS_EXPLORER = ButtonOption("Address explorer")
+ VERIFY_ADDRESS = ButtonOption("Verify address")
def run(self):
button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.ADDRESS_EXPLORER, self.VERIFY_ADDRESS]
@@ -64,7 +63,7 @@ class ToolsImageEntropyLivePreviewView(View):
def run(self):
from seedsigner.gui.screens.tools_screens import ToolsImageEntropyLivePreviewScreen
self.controller.image_entropy_preview_frames = None
- ret = ToolsImageEntropyLivePreviewScreen().display()
+ ret = self.run_screen(ToolsImageEntropyLivePreviewScreen)
if ret == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -83,26 +82,31 @@ class ToolsImageEntropyFinalImageView(View):
from seedsigner.hardware.camera import Camera
# Take the final full-res image
camera = Camera.get_instance()
- camera.start_single_frame_mode(resolution=(720, 480))
+ max_dim = max(self.canvas_width, self.canvas_height)
+
+ # Final image will be at least 4x the number of pixels the screen can
+ # actually display.
+ camera.start_single_frame_mode(resolution=(2*max_dim, 2*max_dim))
+
time.sleep(0.25)
self.controller.image_entropy_final_image = camera.capture_frame()
camera.stop_single_frame_mode()
- # Prep a copy of the image for display. The actual image data is 720x480
- # Present just a center crop and resize it to fit the screen and to keep some of
- # the data hidden.
- display_version = autocontrast(
- self.controller.image_entropy_final_image,
- cutoff=2
- ).crop(
- (120, 0, 600, 480)
- ).resize(
- (self.canvas_width, self.canvas_height), Image.Resampling.BICUBIC
+ # Prep a copy of the image for display:
+ # * Boost the contrast for better presentation (but preserve the original pixels)
+ # * Resize it to fit the screen
+ boosted_version = autocontrast(self.controller.image_entropy_final_image, cutoff=2)
+ display_version = resize_image_to_fill(
+ boosted_version,
+ target_size_x=self.canvas_width,
+ target_size_y=self.canvas_height,
+ sampling_method=Image.Resampling.BICUBIC,
)
- ret = ToolsImageEntropyFinalImageScreen(
+ ret = self.run_screen(
+ ToolsImageEntropyFinalImageScreen,
final_image=display_version
- ).display()
+ )
if ret == RET_CODE__BACK_BUTTON:
# Go back to live preview and reshoot
@@ -120,61 +124,76 @@ class ToolsImageEntropyMnemonicLengthView(View):
def run(self):
button_data = [self.TWELVE_WORDS, self.TWENTYFOUR_WORDS]
- selected_menu_num = ButtonListScreen(
- title=_("Mnemonic Length?"),
+ selected_menu_num = self.run_screen(
+ ButtonListScreen,
+ title=_("Mnemonic Length"),
button_data=button_data,
- ).display()
+ )
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
-
+
mnemonic_length = button_data[selected_menu_num].return_data
- preview_images = self.controller.image_entropy_preview_frames
- seed_entropy_image = self.controller.image_entropy_final_image
+ # The entropy calculation can take time, especially with a full image buffer.
+ # Show a loading spinner to provide feedback during this delay.
+ from seedsigner.gui.screens.screen import LoadingScreenThread
+ self.loading_screen = LoadingScreenThread(text=_("Calculating..."))
+ self.loading_screen.start()
- # Build in some hardware-level uniqueness via CPU unique Serial num
try:
- stream = os.popen("cat /proc/cpuinfo | grep Serial")
- output = stream.read()
- serial_num = output.split(":")[-1].strip().encode('utf-8')
- serial_hash = hashlib.sha256(serial_num)
- hash_bytes = serial_hash.digest()
- except Exception as e:
- logger.info(repr(e), exc_info=True)
- hash_bytes = b'0'
+ preview_images = self.controller.image_entropy_preview_frames
+ seed_entropy_image = self.controller.image_entropy_final_image
- # Build in modest entropy via millis since power on
- millis_hash = hashlib.sha256(hash_bytes + str(time.time()).encode('utf-8'))
- hash_bytes = millis_hash.digest()
+ # Build in some hardware-level uniqueness via CPU unique Serial num
+ try:
+ serial_num = b''
+ with open("/proc/cpuinfo", "r") as f:
+ for line in f:
+ if "Serial" in line:
+ serial_num = line.split(":")[-1].strip().encode('utf-8')
+ break
+ serial_hash = hashlib.sha256(serial_num)
+ hash_bytes = serial_hash.digest()
+ except Exception as e:
+ logger.info(repr(e), exc_info=True)
+ hash_bytes = b'0'
- # Build in better entropy by chaining the preview frames
- for frame in preview_images:
- img_hash = hashlib.sha256(hash_bytes + frame.tobytes())
- hash_bytes = img_hash.digest()
+ # Build in modest entropy via millis since power on
+ millis_hash = hashlib.sha256(hash_bytes + str(time.time()).encode('utf-8'))
+ hash_bytes = millis_hash.digest()
- # Finally build in our headline entropy via the new full-res image
- final_hash = hashlib.sha256(hash_bytes + seed_entropy_image.tobytes()).digest()
+ # Build in better entropy by chaining the preview frames
+ for frame in preview_images:
+ img_hash = hashlib.sha256(hash_bytes + frame.tobytes())
+ hash_bytes = img_hash.digest()
- if mnemonic_length == 12:
- # 12-word mnemonic only uses the first 128 bits / 16 bytes of entropy
- final_hash = final_hash[:16]
+ # Finally build in our headline entropy via the new full-res image
+ final_hash = hashlib.sha256(hash_bytes + seed_entropy_image.tobytes()).digest()
- # Generate the mnemonic
- mnemonic = mnemonic_generation.generate_mnemonic_from_bytes(final_hash)
+ if mnemonic_length == 12:
+ # 12-word mnemonic only uses the first 128 bits / 16 bytes of entropy
+ final_hash = final_hash[:16]
- # Image should never get saved nor stick around in memory
- seed_entropy_image = None
- preview_images = None
- final_hash = None
- hash_bytes = None
- self.controller.image_entropy_preview_frames = None
- self.controller.image_entropy_final_image = None
+ # Generate the mnemonic
+ mnemonic = mnemonic_generation.generate_mnemonic_from_bytes(final_hash)
+
+ # Image should never get saved nor stick around in memory
+ seed_entropy_image = None
+ preview_images = None
+ final_hash = None
+ hash_bytes = None
+ self.controller.image_entropy_preview_frames = None
+ self.controller.image_entropy_final_image = None
+
+ # Add the mnemonic as an in-memory Seed
+ seed = Seed(mnemonic, wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
+ self.controller.storage.set_pending_seed(seed)
+
+ finally:
+ # Stop spinner even if an error occurs
+ self.loading_screen.stop()
- # Add the mnemonic as an in-memory Seed
- seed = Seed(mnemonic, wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
- self.controller.storage.set_pending_seed(seed)
-
# Cannot return BACK to this View
return Destination(SeedWordsWarningView, view_args={"seed_num": None}, clear_history=True)
@@ -197,12 +216,13 @@ class ToolsDiceEntropyMnemonicLengthView(View):
TWENTY_FOUR = ButtonOption(twenty_four, return_data=mnemonic_generation.DICE__NUM_ROLLS__24WORD)
button_data = [TWELVE, TWENTY_FOUR]
- selected_menu_num = ButtonListScreen(
+ selected_menu_num = self.run_screen(
+ ButtonListScreen,
title=_("Mnemonic Length"),
is_bottom_list=True,
is_button_text_centered=True,
button_data=button_data,
- ).display()
+ )
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -223,9 +243,10 @@ class ToolsDiceEntropyEntryView(View):
def run(self):
from seedsigner.gui.screens.tools_screens import ToolsDiceEntropyEntryScreen
- ret = ToolsDiceEntropyEntryScreen(
+ ret = self.run_screen(
+ ToolsDiceEntropyEntryScreen,
return_after_n_chars=self.total_rolls,
- ).display()
+ )
if ret == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -288,11 +309,12 @@ class ToolsCalcFinalWordFinalizePromptView(View):
num_entropy_bits = 3
button_data = [self.COIN_FLIPS, self.SELECT_WORD, self.ZEROS]
- selected_menu_num = ToolsCalcFinalWordFinalizePromptScreen(
+ selected_menu_num = self.run_screen(
+ ToolsCalcFinalWordFinalizePromptScreen,
mnemonic_length=mnemonic_length,
num_entropy_bits=num_entropy_bits,
button_data=button_data,
- ).display()
+ )
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -324,9 +346,10 @@ class ToolsCalcFinalWordCoinFlipsView(View):
else:
total_flips = 3
- ret_val = ToolsCoinFlipEntryScreen(
+ ret_val = self.run_screen(
+ ToolsCoinFlipEntryScreen,
return_after_n_chars=total_flips,
- ).display()
+ )
if ret_val == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -427,12 +450,13 @@ class ToolsCalcFinalWordDoneView(View):
button_data = [self.LOAD, self.DISCARD]
- selected_menu_num = ToolsCalcFinalWordDoneScreen(
+ selected_menu_num = self.run_screen(
+ ToolsCalcFinalWordDoneScreen,
final_word=final_word,
mnemonic_word_length=mnemonic_word_length,
fingerprint=self.controller.storage.get_pending_mnemonic_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)),
button_data=button_data,
- ).display()
+ )
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
@@ -516,10 +540,10 @@ class ToolsAddressExplorerSelectSourceView(View):
class ToolsAddressExplorerAddressTypeView(View):
# TRANSLATOR_NOTE: label for addresses where others send us incoming payments
- RECEIVE = ButtonOption("Receive Addresses")
+ RECEIVE = ButtonOption("Receive addresses")
# TRANSLATOR_NOTE: label for addresses that collect the change from our own outgoing payments
- CHANGE = ButtonOption("Change Addresses")
+ CHANGE = ButtonOption("Change addresses")
def __init__(self, seed_num: int = None, script_type: str = None, custom_derivation: str = None):
@@ -577,8 +601,12 @@ class ToolsAddressExplorerAddressTypeView(View):
wallet_descriptor_display_name = None
if "wallet_descriptor" in data:
- wallet_descriptor_display_name = data["wallet_descriptor"].brief_policy.replace(" (sorted)", "")
- wallet_descriptor_display_name = " / ".join(wallet_descriptor_display_name.split(" of ")) # i18n w/o l10n since coming from non-l10n embit
+ from seedsigner.helpers.embit_utils import get_multisig_policy
+ threshold, n = get_multisig_policy(data["wallet_descriptor"])
+ # TRANSLATOR_NOTE: Multisig policy. For a "2 / 3 multisig" policy, "threshold" = 2; "n" = 3
+ wallet_descriptor_display_name = _("{threshold} / {n} multisig").format(
+ threshold=threshold, n=n
+ )
script_type = data["script_type"] if "script_type" in data else None
@@ -619,6 +647,7 @@ class ToolsAddressExplorerAddressListView(View):
def run(self):
+ from seedsigner.gui.screens.tools_screens import ToolsAddressExplorerAddressListScreen
self.loading_screen = None
addresses = []
@@ -672,31 +701,11 @@ class ToolsAddressExplorerAddressListView(View):
# Everything is set. Stop the loading screen
self.loading_screen.stop()
- for i, address in enumerate(addresses):
- cur_index = i + self.start_index
-
- # Adjust the trailing addr display length based on available room
- # (the index number will push it out on each order of magnitude)
- if cur_index < 10:
- end_digits = -6
- elif cur_index < 100:
- end_digits = -5
- else:
- end_digits = -4
- button_data.append(ButtonOption(f"{cur_index}:{address[:8]}...{address[end_digits:]}", active_button_label=f"{cur_index}:{address}"))
-
- # TRANSLATOR_NOTE: Insert the number of addrs displayed per screen (e.g. "Next 10")
- button_label = _("Next {}").format(addrs_per_screen)
- button_data.append(ButtonOption(button_label, right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT))
-
selected_menu_num = self.run_screen(
- ButtonListScreen,
+ ToolsAddressExplorerAddressListScreen,
title=_("Receive Addrs") if not self.is_change else _("Change Addrs"),
- button_data=button_data,
- button_font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
- button_font_size=GUIConstants.get_button_font_size() + 4,
- is_button_text_centered=False,
- is_bottom_list=True,
+ start_index=self.start_index,
+ addresses=addresses,
selected_button=self.selected_button_index,
scroll_y_initial_offset=self.initial_scroll,
)
diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py
index 66c3a229..8c605da8 100644
--- a/src/seedsigner/views/view.py
+++ b/src/seedsigner/views/view.py
@@ -1,3 +1,4 @@
+import logging
from dataclasses import dataclass
from gettext import gettext as _
from typing import Type
@@ -10,6 +11,9 @@ from seedsigner.models.settings import Settings, SettingsConstants
from seedsigner.models.settings_definition import SettingsDefinition
from seedsigner.models.threads import BaseThread
+logger = logging.getLogger(__name__)
+
+
class BackStackView:
"""
@@ -67,6 +71,7 @@ class View:
self.screen = None
self._redirect: 'Destination' = None
+ self.is_screensaver_allowed = True
def __init__(self):
@@ -221,7 +226,7 @@ class MainMenuView(View):
class PowerOptionsView(View):
RESET = ButtonOption("Restart", SeedSignerIconConstants.RESTART)
- POWER_OFF = ButtonOption("Power Off", SeedSignerIconConstants.POWER)
+ POWER_OFF = ButtonOption("Power off", SeedSignerIconConstants.POWER)
def run(self):
button_data = [self.RESET, self.POWER_OFF]
@@ -242,30 +247,36 @@ class PowerOptionsView(View):
return Destination(PowerOffView)
-
+@dataclass
class RestartView(View):
+
def run(self):
from seedsigner.gui.screens.screen import ResetScreen
- thread = RestartView.DoResetThread()
- thread.start()
+
+ if not self.renderer.is_screenshot_generator:
+ # We don't want the screenshot generator to actually try to do the restart
+ RestartView.DoResetThread().start()
+
self.run_screen(ResetScreen)
class DoResetThread(BaseThread):
def run(self):
+ import os
+ import sys
import time
- from subprocess import call
+ logger.info("Restarting SeedSigner")
# Give the screen just enough time to display the reset message before
# exiting.
time.sleep(0.25)
- # Kill the SeedSigner process; Running the process again.
- # `.*` is a wildcard to detect either `python`` or `python3`.
- if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
- call("kill $(pidof python*) & python /opt/src/main.py", shell=True)
- else:
- call("kill $(ps aux | grep '[p]ython.*main.py' | awk '{print $2}')", shell=True)
+ # Flush any buffered data.
+ sys.stdout.flush()
+ sys.stderr.flush()
+
+ # Replace the current process with a new one.
+ os.execv(sys.executable, [sys.executable] + sys.argv)
@@ -291,7 +302,7 @@ class NotYetImplementedView(View):
title=_("Work In Progress"),
status_headline=_("Not Yet Implemented"),
text=self.text,
- button_data=[ButtonOption("Back to Main Menu")],
+ button_data=[ButtonOption("Back to main menu")],
)
return Destination(MainMenuView)
@@ -339,10 +350,12 @@ class NetworkMismatchErrorView(ErrorView):
self.next_destination = Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=SettingsConstants.SETTING__NETWORK), clear_history=True)
super().__post_init__()
- # TRANSLATOR_NOTE: Inserts mainnet/testnet/regtest and derivation path
- self.text = _("Current network setting ({}) doesn't match {}.").format(
- self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK),
- self.derivation_path,
+ network = _(self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK))
+
+ # TRANSLATOR_NOTE: "network" will be mainnet/testnet/regtest.
+ self.text = _("Current network setting ({network}) doesn't match {derivation_path}.").format(
+ network=network,
+ derivation_path=self.derivation_path,
)
@@ -351,23 +364,52 @@ class NetworkMismatchErrorView(ErrorView):
class UnhandledExceptionView(View):
error: list[str]
+ def __post_init__(self):
+ from seedsigner.hardware.camera import CameraConnectionError
+ super().__post_init__()
+
+ # Camera errors bubble up to here. Reroute to their custom error View.
+ if self.error[0] == CameraConnectionError.__name__:
+ self.set_redirect(
+ Destination(
+ CameraConnectionErrorView,
+ skip_current_view=True,
+ )
+ )
+
+
def run(self):
self.run_screen(
ErrorScreen,
title=_("System Error"),
status_headline=self.error[0],
text=self.error[1] + "\n" + self.error[2],
- allow_text_overflow=True, # Fit what we can, let the rest go off the edges
+ button_data=[ButtonOption("Back to Main Menu")],
)
return Destination(MainMenuView, clear_history=True)
+@dataclass
+class CameraConnectionErrorView(View):
+ def run(self):
+ self.run_screen(
+ ErrorScreen,
+ title=_("Hardware Error"),
+ status_headline=_("Cannot access camera"),
+ text=_("Disconnect power and check for a loose camera connection."),
+ button_data=[ButtonOption("Back to Main Menu")],
+ show_back_button=False,
+ )
+
+ return Destination(MainMenuView, clear_history=True)
+
+
@dataclass
class OptionDisabledView(View):
- UPDATE_SETTING = ButtonOption("Update Setting")
- DONE = ButtonOption("Done")
+ UPDATE_SETTING = ButtonOption("Update setting")
+ DONE = ButtonOption("Back to Main Menu")
settings_attr: str
def __post_init__(self):
@@ -389,7 +431,6 @@ class OptionDisabledView(View):
text=self.error_msg,
button_data=button_data,
show_back_button=False,
- allow_text_overflow=True, # Fit what we can, let the rest go off the edges
)
if button_data[selected_menu_num] == self.UPDATE_SETTING:
@@ -397,3 +438,39 @@ class OptionDisabledView(View):
return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_attr), clear_history=True)
else:
return Destination(MainMenuView, clear_history=True)
+
+
+
+class RemoveMicroSDWarningView(View):
+ CONTINUE = ButtonOption("Continue")
+ SETTINGS = ButtonOption("Settings")
+
+ def run(self):
+ button_data = [self.CONTINUE, self.SETTINGS]
+ selected_menu_num = self.run_screen(
+ WarningScreen,
+ title=_("Action Required"),
+ status_icon_name=SeedSignerIconConstants.MICROSD,
+ status_headline=None,
+ text=_("You must remove the\nMicroSD card to continue."),
+ show_back_button=False,
+ button_data=button_data,
+ )
+
+ if button_data[selected_menu_num] == self.CONTINUE:
+ from seedsigner.hardware.microsd import MicroSD
+ if not MicroSD.get_instance().is_inserted:
+ return Destination(MainMenuView, clear_history=True)
+ else:
+ return Destination(RemoveMicroSDWarningView, clear_history=True)
+
+ elif button_data[selected_menu_num] == self.SETTINGS:
+ from seedsigner.views.settings_views import SettingsEntryUpdateSelectionView
+ return Destination(
+ SettingsEntryUpdateSelectionView,
+ view_args=dict(
+ attr_name=SettingsConstants.SETTING__MICROSD_TOAST_TIMER,
+ blocking_view=RemoveMicroSDWarningView,
+ unblocking_view=MainMenuView
+ )
+ )
\ No newline at end of file
diff --git a/tests/base.py b/tests/base.py
index 74370dc7..77a20bca 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -5,18 +5,22 @@ from typing import Callable
# Prevent importing modules w/Raspi hardware dependencies.
# These must precede any SeedSigner imports.
+sys.modules['numpy'] = MagicMock() # numpy is only in the Raspi requirements; not needed for tests. But is imported in BackgroundImportThread.
sys.modules['seedsigner.gui.renderer'] = MagicMock()
sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock()
sys.modules['seedsigner.gui.toast'] = MagicMock()
sys.modules['seedsigner.views.screensaver'] = MagicMock()
sys.modules['seedsigner.hardware.buttons'] = MagicMock()
-sys.modules['seedsigner.hardware.camera'] = MagicMock()
+sys.modules['seedsigner.hardware.camera.Camera'] = MagicMock()
+sys.modules['seedsigner.hardware.pivideostream'] = MagicMock()
+sys.modules['seedsigner.hardware.st7789_mpy'] = MagicMock()
+sys.modules['seedsigner.hardware.ili9341'] = MagicMock()
from seedsigner.controller import Controller, FlowBasedTestException, StopFlowBasedTest
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON, ButtonOption
from seedsigner.hardware.microsd import MicroSD
from seedsigner.models.settings import Settings
-from seedsigner.views.view import Destination, MainMenuView, UnhandledExceptionView, View
+from seedsigner.views.view import Destination, MainMenuView, View
import logging
logger = logging.getLogger(__name__)
diff --git a/tests/psbt_testing_util.py b/tests/psbt_testing_util.py
new file mode 100644
index 00000000..f47d49ec
--- /dev/null
+++ b/tests/psbt_testing_util.py
@@ -0,0 +1,128 @@
+from binascii import a2b_base64, unhexlify
+from io import BytesIO
+
+from embit.networks import NETWORKS
+from embit.psbt import PSBT, OutputScope
+
+from seedsigner.models.seed import Seed
+
+
+
+class PSBTTestData:
+ """
+ Output data extracted from regtest psbts via the util helper methods below.
+ """
+ seed = Seed("model ensure search plunge galaxy firm exclude brain satoshi meadow cable roast".split())
+ multisig_key_2 = Seed("better gown govern speak spawn vendor exercise item uncle odor sound cat".split())
+ multisig_key_3 = Seed("sign sword lift deer ocean insect web lazy sick pencil start select".split())
+
+ # Base psbts with various inputs for various wallet script types; outputs have been stripped
+ SINGLE_SIG_NATIVE_SEGWIT_1_INPUT = "cHNidP8BADMCAAAAAU4T/0aX9mmNZHyKh+0AHYY+EtdJxndMRra0gn4QPCZdAQAAAAD9////AHAAAABPAQQ1h88DV5lJlYAAAADURWu0eYGA/OFm4Wt3dUJ44H2DzgcPO6J+nF0t9lqMqgLDxbT8Qez0U3W7o1JwT1FQ4fWFAbcK5eJ3NDnETRryMhAPuIL/VAAAgAEAAIAAAACAAAEAiAIAAAAB1Tct22qlCW5iTOZDfXqVu6CKlwdTtrPhbPI7pcTQ/GUBAAAAFxYAFCPpAsYWUNJnTErOjkoChnQylGp6/f///wLNLhoeAQAAABYAFI4lU5EAZIlw/MpX6r3gh1zqazEOAOH1BQAAAAAWABRYjLo+w43NT+5rzgPCNuy79lZjDm4AAAABAR8A4fUFAAAAABYAFFiMuj7Djc1P7mvOA8I27Lv2VmMOAQMEAQAAACIGAvXbqnkmWDXPNEkHznP0l2VFFql2WpgX42yD3Z9fJY/3GA+4gv9UAACAAQAAgAAAAIAAAAAAAwAAAAA="
+ SINGLE_SIG_NESTED_SEGWIT_1_INPUT = "cHNidP8BADMCAAAAAdU3LdtqpQluYkzmQ316lbugipcHU7az4WzyO6XE0PxlAAAAAAD9////AHAAAABPAQQ1h88DIWg8n4AAAACkwPiG7M1QVnBPyAKQofuHpAZAXgXRNyJ15AYUNQRCFQIwD+a/8Dc8z2f/GXiC8z1fbvjC6VqIcciZQ51uU2tq9BAPuIL/MQAAgAEAAIAAAACAAAEAcwIAAAAB9EA7OurOd1aGyQJpQN+S0G+3IF31HO2UFAZnIpALsfQAAAAAAP3///8CAOH1BQAAAAAXqRQDW7vsMAXGa+tKARihpGq6ZwuDYYdxEBAkAQAAABepFGaYxdrjSngzRRSCeg3U/SKQOEfXhwAAAAABASAA4fUFAAAAABepFANbu+wwBcZr60oBGKGkarpnC4NhhwEDBAEAAAABBBYAFDriE9Kbby6bOWzXbXatODyyIRpcIgYDQD4mWFMSOO9mDOb+bwzEiaDf+1dWJICoG4BCig4K2awYD7iC/zEAAIABAACAAAAAgAAAAAAAAAAAAA=="
+ SINGLE_SIG_TAPROOT_1_INPUT = "cHNidP8BADMCAAAAAQljnRdfh8jB/p3Px/Qozd8x0+T4fxI6AwIOf7jQJm6FAAAAAAABAAAAAAAAAABPAQQ1h88DGQXiXoAAAACWEQ4AaLct5K3pnPzxRrHl/74twiazq4XLYi+gGu/5BgPEM2JVPwHaGssfGr25yECm8d73bfEpXMdNNBV3vMW72hAPuIL/VgAAgAEAAIAAAACAAAEA8wIAAAABrv27i6AMCio94q4NQzF6YKxuhgeRW1KQZm2+jWaKGDQBAAAAakcwRAIgM5nApqEOs/MiP9GMA5yKvV3a5qXp8sGfQ8edVizr/ZUCIHhDNQlYvBXCr8iTGycq1/XznJYav88tIJ5Cb7/pdMc8ASECtDcBLkIBPnoXcobfeX36XFp8nD+ZSj1DfxQbQy2JW6/9////AgDh9QUAAAAAIlEgcd/UDa6cM/La5HWyGkTvZOIFp6qTD3MlHZav5/CDmlsT5Vb6AAAAACJRIESp0pjyhqlKbwUtt6WOLKRUhX6Id49uLAK3cd0s+SWrcgAAAAEBKwDh9QUAAAAAIlEgcd/UDa6cM/La5HWyGkTvZOIFp6qTD3MlHZav5/CDmlsBAwQAAAAAIRYXKjsWmE7bdCAHODqvbHkLw5B2jIZj5C3QjXX00E6AdhkAD7iC/1YAAIABAACAAAAAgAAAAAAAAAAAARcgFyo7FphO23QgBzg6r2x5C8OQdoyGY+Qt0I119NBOgHYA"
+ SINGLE_SIG_LEGACY_P2PKH_1_INPUT = "cHNidP8BADMCAAAAAa79u4ugDAoqPeKuDUMxemCsboYHkVtSkGZtvo1mihg0AAAAAAD9////AHIAAABPAQQ1h88DZEKTEIAAAABFOKVFUmpvsmxCAlh1YH6xGPpkLNS0qGLYidlf2TMkHwLTyV38ld8evWpfC6fWTSUoGkkbCu6TfPqYFx9Hd/up4hAPuIL/LAAAgAEAAIAAAACAAAEAjgIAAAABu1uNN8B9NH+3zx1+QaZxkHPSX+7Osu4kTJ/RYweX3i8AAAAAFxYAFE6ANd2QMqwrq2YgFN/s7E9mbmHc/f///wIA4fUFAAAAABl2qRSFDsVqajk9QGakHH5tKXuflIWEBYisBsdMAAEAAAAZdqkUm0UFp//k5knGslifTh4z3o8s6/GIrHEAAAABAwQBAAAAIgYCENx0YsKdM+SNcY34QcTjuaZ9uYhS2tFoLA14o6OlJmwYD7iC/ywAAIABAACAAAAAgAAAAAAAAAAAAA=="
+ MULTISIG_NATIVE_SEGWIT_1_INPUT = "cHNidP8BADMCAAAAAbaPcIOkYKEWkbJpYrT/gbRQYzJ6CSFOJ5eeewflO6jJAAAAAAD9////AHAAAABPAQQ1h88EmoSnUYAAAAIQMkp0H2fIDDprJUDbKzC/+HxUkXMORn4E1dW9s6YYUQLOMo4uvEoFnOcNGcjrzHnZ64ZXsv22WoU7UMUfhSn2NxQPuIL/MAAAgAEAAIAAAACAAgAAgE8BBDWHzwRA+hbPgAAAAtCjPoabT6mduHms6sD7+rUniNXoDSlZvjkiYJpUC65FAqCWpcb6ZLoEcloJpk7vLpNRwwuOFNYyRAvzm28odTsIFAPNCiswAACAAQAAgAAAAIACAACATwEENYfPBMtS+WKAAAACIN1+5Cw5eeTBSKSY3g/NXrRBfXXDPEROQpFRHlmMlIECm0FA2Wj3AxUKxq9C1G9QJ2Ar5TvlMy3/VPUkfQS1HJ4UD4iQRDAAAIABAACAAAAAgAIAAIAAAQCJAgAAAAF1X+6fnEE2Mfa3mgKXygqWGWw2qQb3t1I4fdYJ3cIFowAAAAAA/f///wIA4fUFAAAAACIAIMpj8X3shRYalzfqac0USxW9AqloiW42GgwJjS3jLvSVWxAQJAEAAAAiUSBXg7Hc/rsd/34P6AqwBEDsuXzW+aNgcOsTxa+zGiHOXmsAAAABASsA4fUFAAAAACIAIMpj8X3shRYalzfqac0USxW9AqloiW42GgwJjS3jLvSVAQMEAQAAAAEFaVIhAlnU4yc1877LybCWrJke1kaZFO7WCoP2vDyEog2ERtyfIQKyV23E2SQWvjjaPhSNsRbfaZpPrORdY1Tr73Hm0Ub4nyEDy3sMBTuWlna6t45bdHdl/NzROoKeDVDMwLcIObQE9ZVTriIGAlnU4yc1877LybCWrJke1kaZFO7WCoP2vDyEog2ERtyfHA+4gv8wAACAAQAAgAAAAIACAACAAAAAAAAAAAAiBgKyV23E2SQWvjjaPhSNsRbfaZpPrORdY1Tr73Hm0Ub4nxwDzQorMAAAgAEAAIAAAACAAgAAgAAAAAAAAAAAIgYDy3sMBTuWlna6t45bdHdl/NzROoKeDVDMwLcIObQE9ZUcD4iQRDAAAIABAACAAAAAgAIAAIAAAAAAAAAAAAA="
+ MULTISIG_NESTED_SEGWIT_1_INPUT = "cHNidP8BADMCAAAAAbtbjTfAfTR/t88dfkGmcZBz0l/uzrLuJEyf0WMHl94vAQAAAAD9////AHEAAABPAQQ1h88EmoSnUYAAAAF+MfPe6kyaEEX91G1HKwksGaixXN6RMBTxIcHKjNYYoQJBzkFqUsm3ttoRsGfx+CMj/77pmyyjitqjWZPG4d3RrxQPuIL/MAAAgAEAAIAAAACAAQAAgE8BBDWHzwRA+hbPgAAAAcRAG6PxSUPbFp8IvKVuNlIQn4W5TK1ceLGdKkxdSfktA8QjWNuOjADhspq0onHkGp117FftE91lAYTev8snQsNRFAPNCiswAACAAQAAgAAAAIABAACATwEENYfPBMtS+WKAAAAB6inmZ+P+TS/JJhI/Fog5q8Rx2Nik9EJVEugTuPSDcI4CbvN4qeJalXMOlJpoKnL9Y64icQtz01M9NG6SOhcG8uQUD4iQRDAAAIABAACAAAAAgAEAAIAAAQBzAgAAAAHCfPUdyeETNxf9pkzBP2oOUfgSISrrZi4uj0boKN2PeQEAAAAA/f///wKwqEIGAQAAABepFOV0lUEDO1EMcpnbm8u5gOS//denhwDh9QUAAAAAF6kUURslNGksU91RDOgpxCM7IQOoQaSHAAAAAAEBIADh9QUAAAAAF6kUURslNGksU91RDOgpxCM7IQOoQaSHAQMEAQAAAAEEIgAgDHlipQ8OV+Wko64bycNh+v4LfTW5d8cTv3T1n4XDS/sBBWlSIQIsxclipQM/Gs3kdO+Mlg1gbcMRf1ukSklJhhQ8iMXpSSEDjvwJUpUWl14h2ma/DH5VeAEhM9PxTz70b6lOAgDiWrshA8FwdXOWiPDUIOtV4aCz/ZxfLr9fwrpHQLCUeAtGctJiU64iBgOO/AlSlRaXXiHaZr8MflV4ASEz0/FPPvRvqU4CAOJauxwPuIL/MAAAgAEAAIAAAACAAQAAgAAAAAAAAAAAIgYCLMXJYqUDPxrN5HTvjJYNYG3DEX9bpEpJSYYUPIjF6UkcA80KKzAAAIABAACAAAAAgAEAAIAAAAAAAAAAACIGA8FwdXOWiPDUIOtV4aCz/ZxfLr9fwrpHQLCUeAtGctJiHA+IkEQwAACAAQAAgAAAAIABAACAAAAAAAAAAAAA"
+ MULTISIG_LEGACY_P2SH_1_INPUT = "cHNidP8BADMCAAAAAarS4QMzScnPvNBT2B1I3h80UrSoNuKd9vZkjSl7j2WKAQAAAAD9////AHQAAABPAQQ1h88BD7iC/4AAAC16oZcoUbg2ksYGYWMICvKISPS51jTZLJ3tu4schhGxcAJ/o4LdLxGZHyVDceUz5n6ZtABk9X6nBk3yz/f+eXxkpQgPuIL/LQAAgE8BBDWHzwEDzQorgAAALaD15sHGuMCJCZiW09JfCCZEKGcn9WtB395d/8vj/WC7Am982IdFkEBytDXikxzoeLV5q3kpHg+bfmsmj7ncr1lgCAPNCistAACATwEENYfPAQ+IkESAAAAtcUAwLhCloJPpswRwhGdyG3KP1kY0VAetU6FdAmNrqQoDKFgD/CXYCi5ZHqx4HImYJZtoxpjx60Ki7kvK4Ean8FkID4iQRC0AAIAAAQBzAgAAAAFOE/9Gl/ZpjWR8ioftAB2GPhLXScZ3TEa2tIJ+EDwmXQAAAAAA/f///wI+TSQYAQAAABepFE+Jek+WpV0vCjnVpkLqKfoiLvPAhwDh9QUAAAAAF6kUdHD4u+bbshVEAVKkqRQxu+KR8FKHaAAAAAEDBAEAAAABBGlSIQLUf/ihLNEohwIQiCxVNGmPROLzi8t9FnV6DmfCuEhXHCEDK1QlMflvscOGZmSDWJi90FPUQvNFTQUbkkRdowbR0RghA2T1w+njATChE10XcsbguMj0J5RIzJ5TVN7sbVckbzUWU64iBgLUf/ihLNEohwIQiCxVNGmPROLzi8t9FnV6DmfCuEhXHBAPuIL/LQAAgAAAAAAAAAAAIgYDZPXD6eMBMKETXRdyxuC4yPQnlEjMnlNU3uxtVyRvNRYQA80KKy0AAIAAAAAAAAAAACIGAytUJTH5b7HDhmZkg1iYvdBT1ELzRU0FG5JEXaMG0dEYEA+IkEQtAACAAAAAAAAAAAAA"
+
+ SINGLE_SIG_INPUTS = [
+ SINGLE_SIG_NATIVE_SEGWIT_1_INPUT,
+ SINGLE_SIG_NESTED_SEGWIT_1_INPUT,
+ SINGLE_SIG_TAPROOT_1_INPUT,
+ SINGLE_SIG_LEGACY_P2PKH_1_INPUT,
+ ]
+
+ MULTISIG_INPUTS = [
+ MULTISIG_NATIVE_SEGWIT_1_INPUT,
+ MULTISIG_NESTED_SEGWIT_1_INPUT,
+ MULTISIG_LEGACY_P2SH_1_INPUT
+ ]
+
+ ALL_INPUTS = SINGLE_SIG_INPUTS + MULTISIG_INPUTS
+
+ MULTISIG_NATIVE_SEGWIT_DESCRIPTOR = "wsh(sortedmulti(2,[0fb882ff/48h/1h/0h/2h]tpubDF3QYaRazZ44jHz3jaSPRGCLVYj7D8j4mVVUTCr3CHsfuvoV2Z73eTcvHc8sP3Dj58yEfkG57iBpKTuHv3dNAUcFufCxx26SAbrWque5gts/<0;1>/*,[03cd0a2b/48h/1h/0h/2h]tpubDEPEYgTj1ddmZqDdpiq5Gjttx3CnNSFppSaUa5eHAUVNMD2FE1ihGA2EMP92mzmSUGJsTAgMhBTACd9xsRDB5K4GKJH8RzbRuFUrmVVLR15/<0;1>/*,[0f889044/48h/1h/0h/2h]tpubDFQDKbH2mDqNDPNaUVxM6R5mHhzC4u5F6mNnUkCf6gBMbcENMQ1ZGFLZc3QwgdEv2f34wkTvLMG5kD8AZEZRhat1HQDj42eVxQSxbcqxn31/<0;1>/*))#jsc5nnpk"
+ MULTISIG_NESTED_SEGWIT_DESCRIPTOR = "sh(wsh(sortedmulti(2,[03cd0a2b/48h/1h/0h/1h]tpubDEPEYgTj1ddmXAEnYZG8LjUxRcYNX8xw2QkVEkHj8HVsH3bt3QcS9tgXdfrm31SgNxFY7EfgYcc2BNxcMtg6cB4pdXjND9GjA895aJNsBQp/<0;1>/*,[0fb882ff/48h/1h/0h/1h]tpubDF3QYaRazZ44hqgAAHf16EbhRGBfokgLHcQ5pWB1iQsqLgtTK6rR7XHUBVmwAcv5iU4hQR78PLThbHf5427oAk1hz7Ph8K1r3PUgstnZBvx/<0;1>/*,[0f889044/48h/1h/0h/1h]tpubDFQDKbH2mDqNCqnWUKyj9N2GsGmfJ1mTaoz6BBJ1PRj8FMfd7f3XhFeGBadLtszMp3gRboRmmkWa8TCY8HJN85ouYUALSDnGCzg3vpVDLgd/<0;1>/*)))#per3463t"
+ MULTISIG_LEGACY_P2SH_DESCRIPTOR = "sh(sortedmulti(2,[0fb882ff/45h]tpubD8Nq4eMvojKe9yL3k7vVq42NdriZpkPMCduzQedPRe9Cy6kCfMiGD49oQvomKgrzk4qUb1NVsg6gJeP4MsVTKDUWaubTWHvqTwNg2GCpbZK/<0;1>/*,[0f889044/45h]tpubD8NkS3Gngj7L4FJRYrwojKhsx2seBhrNrXVdvqaUyvtVe1YDCVcziZVa9g3KouXz7FN5CkGBkoC16nmNu2HcG9ubTdtCbSW8DEXSMHmmu62/<0;1>/*,[03cd0a2b/45h]tpubD8HkLLgkdJkVitn1i9CN4HpFKJdom48iKm9PyiXYz5hivn1cGz6H3VeS6ncmCEgamvzQA2Qofu2YSTwWzvuaYWbJDEnvTUtj5R96vACdV6L/<0;1>/*))#8pfqnxa2"
+
+ # Internal self-transfer and change outputs
+ SINGLE_SIG_NATIVE_SEGWIT_CHANGE = "2202022c15efd08c822a6287f1b42b6c30ecced47f1c5484c3e7d72e7680a305893d27180fb882ff5400008001000080000000800100000000000000010308e8fcf105000000000104160014acc1340709e9b7babeff921806f0570c652bd05800"
+ SINGLE_SIG_NATIVE_SEGWIT_SELF_TRANSFER = "2202028d0b3faac6bd38acc2075191bec98612b87100047a7a8d77ed9ef4e3ae03dfc3180fb882ff540000800100008000000080000000000600000001030890d00300000000000104160014f8b609a3b82d8091fb2c99f4f0302bbb838e48ed00"
+ SINGLE_SIG_NESTED_SEGWIT_CHANGE = "01001600145f801cc93ce9357afef02087213775e98b95923d22020236cdb1b950a92dc2f72182c88af0c898a3d8db06dcf57c3ea662850beffc2793180fb882ff3100008001000080000000800100000000000000010308aef3c90500000000010417a9148f71f1b34439b80a1c37a9501d0facf4fa9bf8d38700"
+ SINGLE_SIG_NESTED_SEGWIT_SELF_TRANSFER = "0100160014b574a614ffb9a43ee85f7a08e6ba6f444908406622020212d8d9d112a7b45aae931f59fb459150b8e5fc5377d9efc176253e284e8443a0180fb882ff31000080010000800000008000000000010000000103084cd82b0000000000010417a914a89b66ac7d9de4d7d5da4dae71065147355643148700"
+ SINGLE_SIG_TAPROOT_CHANGE = "010308d50ff205000000000104225120f7fae84e379ba7acfef153518f35c109be2e69dd1658e3a5d2b445abd4b6cf730105200a891a9b5600abef50c78e1c9e5d1ebf95d515667c2033098f8f843fa7f459ae21070a891a9b5600abef50c78e1c9e5d1ebf95d515667c2033098f8f843fa7f459ae19000fb882ff560000800100008000000080010000000000000000"
+ SINGLE_SIG_TAPROOT_SELF_TRANSFER = "01030890d0030000000000010422512045d38d7d2f9b3b2135f93ea1d771495f02eea63deb0bd94c365d8387cf9f5c740105209b36fb4d174ca5ed1a4c0d118bcb75fa4bb5408002b242c773e23df4599f9e6c21079b36fb4d174ca5ed1a4c0d118bcb75fa4bb5408002b242c773e23df4599f9e6c19000fb882ff560000800100008000000080000000000100000000"
+ SINGLE_SIG_LEGACY_P2PKH_CHANGE = "220203b6390d6aa2516f1004fecb1439cfdb2038f6e1f2504376eae6df3dc1634ac191180fb882ff2c000080010000800000008001000000000000000103088e0ff2050000000001041976a9140de643ba8e5659f365b10da0c879a3999f4a348588ac00"
+ SINGLE_SIG_LEGACY_P2PKH_SELF_TRANSFER = "220202c168e3f31b9a7c31aa16b33b3ee47cd64ac5d30d31d9c709e7616bc9389d835e180fb882ff2c0000800100008000000080000000000100000001030890d003000000000001041976a914fef98f1e7c7d975710b8b8b0c12d5737be81e7a688ac00"
+ MULTISIG_NATIVE_SEGWIT_CHANGE = "01016952210234886728fde2bc0e8b9d7f6c4c452c700add80b2690bb237a3c3a6f4e7714c5221025485ce74baeeb49296f486e6780e1e2b46187943de4da3bb8f1a202cd6695393210355da420fc1b72af894a8532bc917528bd0e3bfbc435c33f8f0d9100671084b2353ae22020234886728fde2bc0e8b9d7f6c4c452c700add80b2690bb237a3c3a6f4e7714c521c0fb882ff3000008001000080000000800200008001000000040000002202025485ce74baeeb49296f486e6780e1e2b46187943de4da3bb8f1a202cd66953931c03cd0a2b30000080010000800000008002000080010000000400000022020355da420fc1b72af894a8532bc917528bd0e3bfbc435c33f8f0d9100671084b231c0f88904430000080010000800000008002000080010000000400000001030887f3f105000000000104220020751780ba145dd0a289a88bdccff824116518df23cb674e810654313520eb74db00"
+ MULTISIG_NATIVE_SEGWIT_SELF_TRANSFER = "010169522102519fb0e4b5de132efb1c603e37007baa8de848609627fb1c8af6670f97dba8162102523b7ebc26a2a23d5f863428bf6047cf146904a5ec33724c0be5444c22e2b9d5210302d297b3658cb2643df5bbb5b8edd8d015d96c0a1547e69ef440e6a72b3af70653ae22020302d297b3658cb2643df5bbb5b8edd8d015d96c0a1547e69ef440e6a72b3af7061c0fb882ff300000800100008000000080020000800000000002000000220202523b7ebc26a2a23d5f863428bf6047cf146904a5ec33724c0be5444c22e2b9d51c03cd0a2b300000800100008000000080020000800000000002000000220202519fb0e4b5de132efb1c603e37007baa8de848609627fb1c8af6670f97dba8161c0f88904430000080010000800000008002000080000000000200000001030890d0030000000000010422002074ef7b55285cc4fe433074ed87e54ffc3a2d2d1e89e411112b8869e28d480eaf00"
+ MULTISIG_NESTED_SEGWIT_CHANGE = "0100220020b10852265d3992de36ba959d0ffedac561941910f3c64305c55329c3eded6723010169522102ecf1ff790b21ac813fd16ae103adf49e8783d4e1c69efab0dc65ff21f9e2d0fc21038427b43c1e5009b51c4a52aba48157aa2cb0f3e650ee4abd26578767c2915f882103dad1dced2f90f7d9723a67afbde437ac1a4a3ab7e991502a62442212056bdea653ae220203dad1dced2f90f7d9723a67afbde437ac1a4a3ab7e991502a62442212056bdea61c0fb882ff3000008001000080000000800100008001000000000000002202038427b43c1e5009b51c4a52aba48157aa2cb0f3e650ee4abd26578767c2915f881c03cd0a2b300000800100008000000080010000800100000000000000220202ecf1ff790b21ac813fd16ae103adf49e8783d4e1c69efab0dc65ff21f9e2d0fc1c0f8890443000008001000080000000800100008001000000000000000103089a0ff20500000000010417a914e158e224d933585dcf9077e6f0556ddf0b82c3b18700"
+ MULTISIG_NESTED_SEGWIT_SELF_TRANSFER = "01002200201dc4bf10ca0b4a1106650e2459e8a917a231fd24b4ecdbf1711f4ec063a34df101016952210234361c2816115f8745e95d14243651f622fded6e1f3e30b32ac480e53e3fed3b2102cfd9087055c61d1226649885397872d5f0174186fb2919a7ce0e5149224f78c92103c68ecdc19777b9179b3185a8af96a72baaf3975b7abb125733532031d3b034ab53ae220202cfd9087055c61d1226649885397872d5f0174186fb2919a7ce0e5149224f78c91c0fb882ff300000800100008000000080010000800000000001000000220203c68ecdc19777b9179b3185a8af96a72baaf3975b7abb125733532031d3b034ab1c03cd0a2b30000080010000800000008001000080000000000100000022020234361c2816115f8745e95d14243651f622fded6e1f3e30b32ac480e53e3fed3b1c0f88904430000080010000800000008001000080000000000100000001030890d0030000000000010417a9149c28aee05bdbe48728adc4155c24ab180f899fce8700"
+ MULTISIG_LEGACY_P2SH_CHANGE = "0100695221031ce6ddb7a26336264b132c1b45c3e631e8502bc155226d46fc38d73d57f9e4ff21031d9e6e094413dc3a508518f729af23a35b3943ca79bafbc55afab631595e5ccf2103c3a6cb2786d860aa1ccff81fae79c1a973bc8a79938af6ff3c75fc4f54333d7353ae220203c3a6cb2786d860aa1ccff81fae79c1a973bc8a79938af6ff3c75fc4f54333d73100fb882ff2d00008001000000000000002202031d9e6e094413dc3a508518f729af23a35b3943ca79bafbc55afab631595e5ccf1003cd0a2b2d00008001000000000000002202031ce6ddb7a26336264b132c1b45c3e631e8502bc155226d46fc38d73d57f9e4ff100f8890442d0000800100000000000000010308000ff20500000000010417a91402e6f3cb4a98d88fbedf0e43869ce9e1a01e8d9f8700"
+ MULTISIG_LEGACY_P2SH_SELF_TRANSFER = "01006952210283a03b8fa4cce258e536514416f448cf20aca8ef42492413c149a329029adb4e210346b5d12c00554bce6920e60d647893db6555e0844b8583747bc0ca1ecaba3e1c2103bfc0f7151411ace2d2eef986efca7873dabdc0f6f1bbea7bd3c863be1f2e00de53ae220203bfc0f7151411ace2d2eef986efca7873dabdc0f6f1bbea7bd3c863be1f2e00de100fb882ff2d000080000000000100000022020346b5d12c00554bce6920e60d647893db6555e0844b8583747bc0ca1ecaba3e1c1003cd0a2b2d000080000000000100000022020283a03b8fa4cce258e536514416f448cf20aca8ef42492413c149a329029adb4e100f8890442d000080000000000100000001030890d0030000000000010417a914505efc1adc72f354e827da94b49dec46d80717068700"
+
+ # External receive outputs
+ recipient_seed = Seed("shove album flame dad equal cook spike cheap hollow exit great forest".split())
+ recipient_multisig_key_2 = Seed("grow curve arrive reflect alarm water black funny comfort match attend tired".split())
+ recipient_multisig_key_3 = Seed("hunt air season climb grocery shadow monitor please virtual cave young denial".split())
+ SINGLE_SIG_NATIVE_SEGWIT_RECEIVE = "01030890d00300000000000104160014fc243291eec14337ce81817778c251e92da7c45600"
+ SINGLE_SIG_NESTED_SEGWIT_RECEIVE = "01030890d0030000000000010417a914094a43bc6e1632bd092f1beb8a67dc63160be6ff8700"
+ SINGLE_SIG_TAPROOT_RECEIVE = "01030890d003000000000001042251200d30561bed15df986cafbfb58150a2208cf422b4efa6891d0534fba834bf06c600"
+ SINGLE_SIG_LEGACY_P2PKH_RECEIVE = "01030890d003000000000001041976a91467df58b6a9c4c0a2569595036fe6f928390ecd9588ac00"
+ MULTISIG_NATIVE_SEGWIT_RECEIVE = "01030890d003000000000001042200200936ff1943bbe5c037ad9e9839ca3effe24d8b22fd38cc2601c9007cc0f210a100"
+ MULTISIG_NESTED_SEGWIT_RECEIVE = "01030890d0030000000000010417a9141d3c080d4b05358b8cc439ef12534250563f34a08700"
+ MULTISIG_LEGACY_P2SH_RECEIVE = "01030890d0030000000000010417a914fa70ebb69e283b493770c5a8fa19ec76da321de68700"
+
+ ALL_EXTERNAL_OUTPUTS = [
+ SINGLE_SIG_NATIVE_SEGWIT_RECEIVE,
+ SINGLE_SIG_NESTED_SEGWIT_RECEIVE,
+ SINGLE_SIG_TAPROOT_RECEIVE,
+ SINGLE_SIG_LEGACY_P2PKH_RECEIVE,
+ MULTISIG_NATIVE_SEGWIT_RECEIVE,
+ MULTISIG_NESTED_SEGWIT_RECEIVE,
+ MULTISIG_LEGACY_P2SH_RECEIVE,
+ ]
+
+
+ """
+ Utils to generate the above PSBT data snippets
+ """
+ def dump_outputs(psbt_base64: str):
+ psbt = PSBT.parse(a2b_base64(psbt_base64))
+ for output in psbt.outputs:
+ out_type = "external recipient"
+ if output.bip32_derivations:
+ pubkey, der = list(output.bip32_derivations.items())[0]
+ if der.derivation[-2] == 1:
+ out_type = "change"
+ else:
+ out_type = "self-transfer"
+
+ elif output.taproot_bip32_derivations:
+ pubkey, (leaf_hashes, der) = list(output.taproot_bip32_derivations.items())[0]
+ if der.derivation[-2] == 1:
+ out_type = "change"
+ else:
+ out_type = "self-transfer"
+
+ # Must specify `version=2` so script_pubkey is included
+ print(output.script_pubkey.address(NETWORKS["regtest"]), out_type, output.serialize(version=2).hex(), '\n')
+
+
+ def strip_psbt_outputs(psbt_base64: str):
+ psbt = PSBT.parse(a2b_base64(psbt_base64))
+ psbt.outputs.clear()
+ print(psbt)
+
+
+
+def create_output(output_hex: str, value: int = None) -> OutputScope:
+ """
+ Given a change, self-transfer, or receive output's serialized hex, create the
+ corresponding OutputScope that can be added to a PSBT.
+
+ Optionally override the output's `value`.
+ """
+ output = OutputScope.read_from(BytesIO(unhexlify(output_hex)))
+ if value is not None:
+ output.value = value
+ return output
diff --git a/tests/requirements.txt b/tests/requirements.txt
index d42adea7..51e26f39 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -1,3 +1,3 @@
-coverage==7.3.1
+coverage==7.6.12
pytest==7.4.2
pytest-cov==4.1.0
diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py
index 27ea2e18..c8467e27 100644
--- a/tests/screenshot_generator/generator.py
+++ b/tests/screenshot_generator/generator.py
@@ -1,10 +1,13 @@
import embit
+import os
import pathlib
import pytest
-import os
import random
import sys
import time
+from contextlib import contextmanager
+from dataclasses import dataclass
+from PIL import ImageFont
from unittest.mock import Mock, patch, MagicMock
from embit import compact
@@ -13,42 +16,40 @@ from embit.script import Script
# Prevent importing modules w/Raspi hardware dependencies.
# These must precede any SeedSigner imports.
-sys.modules['seedsigner.hardware.ST7789'] = MagicMock()
+sys.modules['seedsigner.hardware.displays.st7789_mpy'] = MagicMock()
+sys.modules['seedsigner.hardware.displays.ili9341'] = MagicMock()
sys.modules['seedsigner.views.screensaver.ScreensaverScreen'] = MagicMock()
sys.modules['RPi'] = MagicMock()
sys.modules['RPi.GPIO'] = MagicMock()
-sys.modules['seedsigner.hardware.camera'] = MagicMock()
+sys.modules['seedsigner.hardware.camera.Camera'] = MagicMock()
sys.modules['seedsigner.hardware.microsd'] = MagicMock()
-# Force the screenshots to mimic Pi Zero's output without libraqm
-patch('PIL.ImageFont.core.HAVE_RAQM', False).start()
-
from seedsigner.controller import Controller
from seedsigner.gui.renderer import Renderer
+from seedsigner.gui.screens.screen import BaseScreen
from seedsigner.gui.screens.seed_screens import SeedAddPassphraseScreen
from seedsigner.gui.toast import RemoveSDCardToastManagerThread, SDCardStateChangeToastManagerThread
from seedsigner.gui.toast import DefaultToast, InfoToast, SuccessToast, WarningToast, ErrorToast, DireWarningToast
from seedsigner.hardware.microsd import MicroSD
from seedsigner.helpers import embit_utils
from seedsigner.models.decode_qr import DecodeQR
+from seedsigner.models.encode_qr import BaseQrEncoder
from seedsigner.models.psbt_parser import OPCODES, PSBTParser
from seedsigner.models.qr_type import QRType
from seedsigner.models.seed import Seed
from seedsigner.models.settings import Settings
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
-from seedsigner.views import (MainMenuView, PowerOptionsView, RestartView, NotYetImplementedView, UnhandledExceptionView,
+from seedsigner.views import (MainMenuView, PowerOptionsView, RestartView, RemoveMicroSDWarningView, NotYetImplementedView, UnhandledExceptionView,
psbt_views, seed_views, settings_views, tools_views, scan_views)
from seedsigner.views.screensaver import OpeningSplashView
-from seedsigner.views.view import NetworkMismatchErrorView, OptionDisabledView, PowerOffView
+from seedsigner.views.view import CameraConnectionErrorView, NetworkMismatchErrorView, OptionDisabledView, PowerOffView
from .utils import ScreenshotComplete, ScreenshotConfig, ScreenshotRenderer
import warnings; warnings.warn = lambda *args, **kwargs: None
-
-
# Dynamically generate a pytest test run for each locale
-@pytest.mark.parametrize("locale", [x for x, y in SettingsConstants.ALL_LOCALES])
+@pytest.mark.parametrize("locale", [x for x, y in SettingsConstants.get_detected_languages()])
def test_generate_all(locale, target_locale):
"""
`target_locale` is a fixture created in conftest.py via the `--locale` command line arg.
@@ -58,6 +59,11 @@ def test_generate_all(locale, target_locale):
if target_locale and locale != target_locale:
pytest.skip(f"Skipping {locale}")
+ if not ImageFont.core.HAVE_RAQM:
+ # We can't generate pixel-perfect screenshots that match what gets rendered on
+ # the device if we don't have libraqm.
+ pytest.fail("libraqm is not installed.")
+
generate_screenshots(locale)
@@ -66,7 +72,10 @@ def test_generate_all(locale, target_locale):
Set up global test data that will be re-used across a variety of screenshots and for
all locales.
**************************************************************************************"""
-BASE64_PSBT_1 = """cHNidP8BAP06AQIAAAAC5l4E3oEjI+H0im8t/K2nLmF5iJFdKEiuQs8ESveWJKcAAAAAAP3///8iBZMRhYIq4s/LmnTmKBi79M8ITirmsbO++63evK4utwAAAAAA/f///wZYQuoDAAAAACIAIAW5jm3UnC5fyjKCUZ8LTzjENtb/ioRTaBMXeSXsB3n+bK2fCgAAAAAWABReJY7akT1+d+jx475yBRWORdBd7VxbUgUAAAAAFgAU4wj9I/jB3GjNQudNZAca+7g9R16iWtYOAAAAABYAFIotPApLZlfscg8f3ppKqO3qA5nv7BnMFAAAAAAiACAs6SGc8qv4FwuNl0G0SpMZG8ODUEk5RXiWUcuzzw5iaRSfAhMAAAAAIgAgW0f5QxQIgVCGQqKzsvfkXZjUxdFop5sfez6Pt8mUbmZ1AgAAAAEAkgIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BQIRAgEB/////wJAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQErQL5AJQAAAAAiACCET6KNi75K8K4a2BYS4ZT+N4s8WwOBKOmOohRYkGHV0QEFR1EhArGhNdUqlR4BAOLGTMrY2ZJYTQNRudp7fU7i8crRJqgEIQNDxn7PjUzvsP6KYw4s7dmoZE0qO1K6MaM+2ScRZ7hyxFKuIgYCsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQcc8XaCjAAAIABAACAAAAAgAIAAIAAAAAAAwAAACIGA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEHCK94akwAACAAQAAgAAAAIACAACAAAAAAAMAAAAAAQCSAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8FAhACAQH/////AkC+QCUAAAAAIgAghE+ijYu+SvCuGtgWEuGU/jeLPFsDgSjpjqIUWJBh1dEAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABAStAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAQVHUSECsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQhA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEUq4iBgKxoTXVKpUeAQDixkzK2NmSWE0DUbnae31O4vHK0SaoBBxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAADAAAAIgYDQ8Z+z41M77D+imMOLO3ZqGRNKjtSujGjPtknEWe4csQcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAAAwAAAAABAUdRIQJ5XLCBS0hdo4NANq4lNhimzhyHj7dvObmPAwNj8L2xASEC9mwwoH28/WHnxbb6z05sJ/lHuvrLs/wOooHgFn5ulI1SriICAnlcsIFLSF2jg0A2riU2GKbOHIePt285uY8DA2PwvbEBHCK94akwAACAAQAAgAAAAIACAACAAQAAAAEAAAAiAgL2bDCgfbz9YefFtvrPTmwn+Ue6+suz/A6igeAWfm6UjRxzxdoKMAAAgAEAAIAAAACAAgAAgAEAAAABAAAAAAAAAAEBR1EhAgpbWcEh7rgvRE5UaCcqzWL/TR1B/DS8UeZsKVEvuKLrIQOwLg0emiQbbxafIh69Xjtpj4eclsMhKq1y/7vYDdE7LVKuIgICCltZwSHuuC9ETlRoJyrNYv9NHUH8NLxR5mwpUS+4ouscc8XaCjAAAIABAACAAAAAgAIAAIAAAAAABQAAACICA7AuDR6aJBtvFp8iHr1eO2mPh5yWwyEqrXL/u9gN0TstHCK94akwAACAAQAAgAAAAIACAACAAAAAAAUAAAAAAQFHUSECk50GLh/YhZaLJkDq/dugU3H/WvE6rTgQuY6N57pI4ykhA/H8MdLVP9SA/Hg8l3hvibSaC1bCBzwz7kTW+rsEZ8uFUq4iAgKTnQYuH9iFlosmQOr926BTcf9a8TqtOBC5jo3nukjjKRxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAAGAAAAIgID8fwx0tU/1ID8eDyXeG+JtJoLVsIHPDPuRNb6uwRny4UcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAABgAAAAA="""
+# Single sig ("abandon" test wallet) tx; 1mil sat input, 1 external output, 1 self-transfer, 1 change output, 400 sat fee
+BASE64_SINGLE_SIG_PSBT = """cHNidP8BAJACAAAAAT8SmJzLhTMNgtn9QOmBmet0nnqqIJpsgpgBN5JWNJCxAQAAAAD9////A5CfBwAAAAAAFgAULzSqHPAKU7BVopGgOn1F8KaYi1KQ0AMAAAAAABYAFGQh2ztS8DzX4kGVKUKQhFPrlNNIkNADAAAAAAAWABRvoBZQCjxqc367Jg4t3KeLqSNFWGYAAABPAQQ1h88DDvSxr4AAAAA8jCA37kwWIdoNNI21EWNwmmItDSg43ebYQZxR9jAcYgO4jg++P2RjN+2TvAwPO4Q/z30lieXsiEdU5kAgJ6iQtBBzxdoKVAAAgAEAAIAAAACAAAEAcQIAAAABF84F9MpvLC1H3Cyews1xoNZ4ch3uJMu8jonehCIqmScAAAAAAP3///8CM6/2KQEAAAAWABQQumvlzzcWsGXBNIOliqXTvr9YxEBCDwAAAAAAFgAU0MSj7wnpl7bpnjl+UY/j5BoRjKFNAAAAAQEfQEIPAAAAAAAWABTQxKPvCemXtumeOX5Rj+PkGhGMoQEDBAEAAAAiBgLnqyU3tdSelwMJquBunknzbOHJ/rvUTsjg0cygtPnDGRhzxdoKVAAAgAEAAIAAAACAAAAAAAAAAAAAIgIDXUnszVTQCZ5DZ2J3x6bUYl1hHaiKXfSb+VF6d5Gnd6UYc8XaClQAAIABAACAAAAAgAEAAAAAAAAAAAAiAgPu7SBaaQIv7UpioCRX82mbGcBr90v4AazG2a6EvBap4RhzxdoKVAAAgAEAAIAAAACAAAAAAAEAAAAA"""
+
+BASE64_MULTISIG_PSBT = """cHNidP8BAP06AQIAAAAC5l4E3oEjI+H0im8t/K2nLmF5iJFdKEiuQs8ESveWJKcAAAAAAP3///8iBZMRhYIq4s/LmnTmKBi79M8ITirmsbO++63evK4utwAAAAAA/f///wZYQuoDAAAAACIAIAW5jm3UnC5fyjKCUZ8LTzjENtb/ioRTaBMXeSXsB3n+bK2fCgAAAAAWABReJY7akT1+d+jx475yBRWORdBd7VxbUgUAAAAAFgAU4wj9I/jB3GjNQudNZAca+7g9R16iWtYOAAAAABYAFIotPApLZlfscg8f3ppKqO3qA5nv7BnMFAAAAAAiACAs6SGc8qv4FwuNl0G0SpMZG8ODUEk5RXiWUcuzzw5iaRSfAhMAAAAAIgAgW0f5QxQIgVCGQqKzsvfkXZjUxdFop5sfez6Pt8mUbmZ1AgAAAAEAkgIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BQIRAgEB/////wJAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQErQL5AJQAAAAAiACCET6KNi75K8K4a2BYS4ZT+N4s8WwOBKOmOohRYkGHV0QEFR1EhArGhNdUqlR4BAOLGTMrY2ZJYTQNRudp7fU7i8crRJqgEIQNDxn7PjUzvsP6KYw4s7dmoZE0qO1K6MaM+2ScRZ7hyxFKuIgYCsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQcc8XaCjAAAIABAACAAAAAgAIAAIAAAAAAAwAAACIGA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEHCK94akwAACAAQAAgAAAAIACAACAAAAAAAMAAAAAAQCSAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8FAhACAQH/////AkC+QCUAAAAAIgAghE+ijYu+SvCuGtgWEuGU/jeLPFsDgSjpjqIUWJBh1dEAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABAStAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAQVHUSECsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQhA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEUq4iBgKxoTXVKpUeAQDixkzK2NmSWE0DUbnae31O4vHK0SaoBBxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAADAAAAIgYDQ8Z+z41M77D+imMOLO3ZqGRNKjtSujGjPtknEWe4csQcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAAAwAAAAABAUdRIQJ5XLCBS0hdo4NANq4lNhimzhyHj7dvObmPAwNj8L2xASEC9mwwoH28/WHnxbb6z05sJ/lHuvrLs/wOooHgFn5ulI1SriICAnlcsIFLSF2jg0A2riU2GKbOHIePt285uY8DA2PwvbEBHCK94akwAACAAQAAgAAAAIACAACAAQAAAAEAAAAiAgL2bDCgfbz9YefFtvrPTmwn+Ue6+suz/A6igeAWfm6UjRxzxdoKMAAAgAEAAIAAAACAAgAAgAEAAAABAAAAAAAAAAEBR1EhAgpbWcEh7rgvRE5UaCcqzWL/TR1B/DS8UeZsKVEvuKLrIQOwLg0emiQbbxafIh69Xjtpj4eclsMhKq1y/7vYDdE7LVKuIgICCltZwSHuuC9ETlRoJyrNYv9NHUH8NLxR5mwpUS+4ouscc8XaCjAAAIABAACAAAAAgAIAAIAAAAAABQAAACICA7AuDR6aJBtvFp8iHr1eO2mPh5yWwyEqrXL/u9gN0TstHCK94akwAACAAQAAgAAAAIACAACAAAAAAAUAAAAAAQFHUSECk50GLh/YhZaLJkDq/dugU3H/WvE6rTgQuY6N57pI4ykhA/H8MdLVP9SA/Hg8l3hvibSaC1bCBzwz7kTW+rsEZ8uFUq4iAgKTnQYuH9iFlosmQOr926BTcf9a8TqtOBC5jo3nukjjKRxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAAGAAAAIgID8fwx0tU/1ID8eDyXeG+JtJoLVsIHPDPuRNb6uwRny4UcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAABgAAAAA="""
mnemonic_12b = ["abandon"] * 11 + ["about"]
seed_12b = Seed(mnemonic=mnemonic_12b, wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
@@ -84,7 +93,7 @@ def add_op_return_to_psbt(psbt: PSBT, raw_payload_data: bytes):
# Prep a PSBT with a human-readable OP_RETURN
raw_payload_data = "Chancellor on the brink of third bailout for banks".encode()
-psbt = PSBT.from_base64(BASE64_PSBT_1)
+psbt = PSBT.from_base64(BASE64_MULTISIG_PSBT)
# Simplify the output side
output = psbt.outputs[-1]
@@ -95,7 +104,7 @@ BASE64_PSBT_WITH_OP_RETURN_TEXT = add_op_return_to_psbt(psbt, raw_payload_data)
# Prep a PSBT with a (repeatably) random 80-byte OP_RETURN
random.seed(6102)
-BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES = add_op_return_to_psbt(PSBT.from_base64(BASE64_PSBT_1), random.randbytes(80))
+BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES = add_op_return_to_psbt(PSBT.from_base64(BASE64_MULTISIG_PSBT), random.randbytes(80))
mnemonic_12 = "forum undo fragile fade shy sign arrest garment culture tube off merit".split()
mnemonic_24 = "attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire".split()
@@ -106,6 +115,26 @@ seed_24_w_passphrase = Seed(mnemonic=mnemonic_24, passphrase="some-PASS*phrase9"
MULTISIG_WALLET_DESCRIPTOR = """wsh(sortedmulti(1,[22bde1a9/48h/1h/0h/2h]tpubDFfsBrmpj226ZYiRszYi2qK6iGvh2vkkghfGB2YiRUVY4rqqedHCFEgw12FwDkm7rUoVtq9wLTKc6BN2sxswvQeQgp7m8st4FP8WtP8go76/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*))#3jhtf6yx"""
+# Wrap QRDisplayScreen's `render_brightness_tip` in a simple View + Screen so we
+# can call it outside of its child thread and generate a screenshot.
+class SeedExportXpubQR_ScreenBrightnessView(seed_views.SeedExportXpubQRDisplayView):
+ @dataclass
+ class QRDisplayScreenBrightnessTipScreen(BaseScreen):
+ qr_encoder: BaseQrEncoder = None
+
+ def _render(self):
+ from seedsigner.gui.screens.screen import QRDisplayScreen
+ image = self.qr_encoder.part_to_image(self.qr_encoder.cur_part(), 240, 240, border=2, background_color="white")
+ QRDisplayScreen.QRDisplayThread.render_brightness_tip(None, image)
+ self.renderer.show_image(image)
+
+ def run(self):
+ self.run_screen(
+ SeedExportXpubQR_ScreenBrightnessView.QRDisplayScreenBrightnessTipScreen,
+ qr_encoder=self.qr_encoder, # initialized by SeedExportXpubQRDisplayView
+ )
+
+
def generate_screenshots(locale):
"""
@@ -147,7 +176,7 @@ def generate_screenshots(locale):
# Load a PSBT into memory
decoder = DecodeQR()
- decoder.add_data(BASE64_PSBT_1)
+ decoder.add_data(BASE64_MULTISIG_PSBT)
controller.psbt = decoder.get_psbt()
controller.psbt_seed = seed_12b
@@ -160,52 +189,101 @@ def generate_screenshots(locale):
"addr_format": embit_utils.parse_derivation_path(derivation_path)
}
- # Automatically populate all Settings options Views
- settings_views_list = []
- settings_views_list.append(ScreenshotConfig(settings_views.SettingsMenuView))
- settings_views_list.append(
- ScreenshotConfig(
- settings_views.SettingsMenuView,
- dict(
- visibility=SettingsConstants.VISIBILITY__ADVANCED,
- selected_attr=SettingsConstants.SETTING__ELECTRUM_SEEDS,
- initial_scroll=240, # Just guessing how many pixels to scroll down
- ),
- screenshot_name="SettingsMenuView__Advanced"
- )
- )
-
# so we get a choice for transcribe seed qr format
controller.settings.set_value(
attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR,
value=SettingsConstants.OPTION__ENABLED
)
- for settings_entry in SettingsDefinition.settings_entries:
- if settings_entry.visibility == SettingsConstants.VISIBILITY__HIDDEN:
- continue
- settings_views_list.append(ScreenshotConfig(settings_views.SettingsEntryUpdateSelectionView, dict(attr_name=settings_entry.attr_name), screenshot_name=f"SettingsEntryUpdateSelectionView_{settings_entry.attr_name}"))
+ # Automatically populate all Settings options Views
+ settings_views_list = []
+ def add_settings_entries(visibility = SettingsConstants.VISIBILITY__GENERAL):
+ for settings_entry in SettingsDefinition.settings_entries:
+ if settings_entry.visibility != visibility:
+ continue
- settingsqr_data_persistent = f"settings::v1 name=English_noob_mode persistent=E coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E locale={locale}"
- settingsqr_data_not_persistent = f"settings::v1 name=Mode_Ephemeral persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E locale={locale}"
+ if settings_entry.attr_name == SettingsConstants.SETTING__LOCALE:
+ # Locale selection has its own dedicated View
+ settings_views_list.append(ScreenshotConfig(settings_views.LocaleSelectionView))
+ else:
+ # Generic SettingsEntry selection View
+ settings_views_list.append(ScreenshotConfig(settings_views.SettingsEntryUpdateSelectionView, dict(attr_name=settings_entry.attr_name), screenshot_name=f"SettingsEntryUpdateSelectionView_{settings_entry.attr_name}"))
+ # Add the top level "General" settings menu and entries
+ settings_views_list.append(ScreenshotConfig(settings_views.SettingsMenuView))
+ add_settings_entries(SettingsConstants.VISIBILITY__GENERAL)
- # Set up screenshot-specific callbacks to inject data before the View is run and
- # reset data after the View is run.
- def load_basic_psbt_cb():
+ # Add the "Advanced" menu...
+ settings_views_list.append(
+ ScreenshotConfig(
+ settings_views.SettingsMenuView,
+ dict(
+ visibility=SettingsConstants.VISIBILITY__ADVANCED,
+ ),
+ screenshot_name="SettingsMenuView__Advanced"
+ )
+ )
+
+ # ...and Advanced entries
+ add_settings_entries(SettingsConstants.VISIBILITY__ADVANCED)
+
+ # Render the nested "Advanced" -> "Hardware" submenu
+ settings_views_list.append(
+ ScreenshotConfig(
+ settings_views.SettingsMenuView,
+ dict(visibility=SettingsConstants.VISIBILITY__HARDWARE),
+ screenshot_name="SettingsMenuView__Hardware"
+ )
+ )
+ add_settings_entries(SettingsConstants.VISIBILITY__HARDWARE)
+
+ settingsqr_data_persistent = f"settings::v1 name=English_noob_mode persistent=E xpub_qr=urca,sta denom=thr network=M qr_density=M sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E locale={locale}"
+ settingsqr_data_not_persistent = f"settings::v1 name=Mode_Ephemeral persistent=D xpub_qr=urca,sta denom=thr network=M qr_density=M sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E locale={locale}"
+
+ # Set up mocks to provide whatever temporary data/state a particular screenshot
+ # might need.
+ @contextmanager
+ def mock_load_psbt(base64_psbt: str, seed: Seed = seed_12b):
+ """
+ Reusable utility for other context managers to mock loading a PSBT into the
+ Controller.
+ """
decoder = DecodeQR()
- decoder.add_data(BASE64_PSBT_1)
- controller.psbt = decoder.get_psbt()
- controller.psbt_seed = seed_12b
- controller.multisig_wallet_descriptor = None
+ decoder.add_data(base64_psbt)
+ with patch.object(controller, 'psbt', decoder.get_psbt()):
+ with patch.object(controller, 'psbt_seed', seed):
+ with patch.object(controller, 'psbt_parser', PSBTParser(p=controller.psbt, seed=seed)):
+ yield
- def load_multisig_wallet_descriptor_cb():
- controller.multisig_wallet_descriptor = embit.descriptor.Descriptor.from_string(MULTISIG_WALLET_DESCRIPTOR)
+ @contextmanager
+ def mock_single_sig_psbt_loaded():
+ with mock_load_psbt(BASE64_SINGLE_SIG_PSBT):
+ yield
- def load_address_verification_data_cb():
- controller.unverified_address = dict(
+ @contextmanager
+ def mock_multisig_psbt_loaded():
+ with mock_load_psbt(BASE64_MULTISIG_PSBT):
+ yield
+
+
+ @contextmanager
+ def mock_multisig_wallet_descriptor_loaded():
+ with patch.object(controller, 'multisig_wallet_descriptor', embit.descriptor.Descriptor.from_string(MULTISIG_WALLET_DESCRIPTOR)):
+ yield
+
+
+ @contextmanager
+ def mock_multisig_psbt_and_descriptor_loaded():
+ with mock_multisig_psbt_loaded():
+ with mock_multisig_wallet_descriptor_loaded():
+ yield
+
+
+ @contextmanager
+ def mock_address_verification_data_loaded():
+ fake_addr_verification_data = dict(
# These are all totally fake data
address="bc1q6p00wazu4nnqac29fvky6vhjnnhku5u2g9njss62rvy7e0yuperq86f5ek",
network=SettingsConstants.MAINNET,
@@ -215,35 +293,38 @@ def generate_screenshots(locale):
verified_index=5,
verified_index_is_change=False
)
+ with patch.object(controller, 'unverified_address', fake_addr_verification_data):
+ yield
- def PSBTSelectSeedView_cb_before():
+ @contextmanager
+ def mock_controller_psbt_seed_empty():
# Have to ensure this is cleared out in order to get the seed selection screen
- controller.psbt_seed = None
+ with patch.object(controller, 'psbt_seed', None):
+ yield
- def PSBTOverviewView_op_return_cb_before():
- controller.psbt_seed = seed_12b
- decoder = DecodeQR()
- decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_TEXT)
- controller.psbt = decoder.get_psbt()
- controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b)
-
+ @contextmanager
+ def mock_psbt_with_op_return_loaded():
+ with mock_load_psbt(BASE64_PSBT_WITH_OP_RETURN_TEXT):
+ yield
- def PSBTOpReturnView_raw_hex_data_cb_before():
- decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES)
- controller.psbt = decoder.get_psbt()
- controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b)
+
+ @contextmanager
+ def mock_psbt_with_op_return_raw_bytes_loaded():
+ with mock_load_psbt(BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES):
+ yield
screenshot_sections = {
"Main Menu Views": [
- ScreenshotConfig(OpeningSplashView, dict(is_screenshot_renderer=True, force_partner_logos=True)),
- ScreenshotConfig(OpeningSplashView, dict(is_screenshot_renderer=True, force_partner_logos=False), screenshot_name="OpeningSplashView_no_partner_logos"),
+ ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=True)),
+ ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=False), screenshot_name="OpeningSplashView_no_partner_logos"),
ScreenshotConfig(MainMenuView),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SDCardStateChangeToast_removed', toast_thread=SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__REMOVED, activation_delay=0, duration=0)),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SDCardStateChangeToast_inserted', toast_thread=SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__INSERTED, activation_delay=0, duration=0)),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_RemoveSDCardToast', toast_thread=RemoveSDCardToastManagerThread(activation_delay=0, duration=0)),
+ ScreenshotConfig(RemoveMicroSDWarningView),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_DefaultToast', toast_thread=DefaultToast("This is a default text toast!", activation_delay=0, duration=0)),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_InfoToast', toast_thread=InfoToast("This is an info toast!", activation_delay=0, duration=0)),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SuccessToast', toast_thread=SuccessToast("This is a success toast!", activation_delay=0, duration=0)),
@@ -269,32 +350,33 @@ def generate_screenshots(locale):
ScreenshotConfig(seed_views.SeedReviewPassphraseView),
ScreenshotConfig(seed_views.SeedOptionsView, dict(seed_num=0)),
- ScreenshotConfig(seed_views.SeedBackupView, dict(seed_num=0)),
- ScreenshotConfig(seed_views.SeedExportXpubSigTypeView, dict(seed_num=0)),
- ScreenshotConfig(seed_views.SeedExportXpubScriptTypeView, dict(seed_num=0, sig_type="msig")),
- ScreenshotConfig(seed_views.SeedExportXpubCustomDerivationView, dict(seed_num=0, sig_type="ss", script_type="")),
- ScreenshotConfig(seed_views.SeedExportXpubCoordinatorView, dict(seed_num=0, sig_type="ss", script_type="nat")),
- ScreenshotConfig(seed_views.SeedExportXpubWarningView, dict(seed_num=0, sig_type="msig", script_type="nes", coordinator="spd", custom_derivation="")),
- ScreenshotConfig(seed_views.SeedExportXpubDetailsView, dict(seed_num=0, sig_type="ss", script_type="nat", coordinator="bw", custom_derivation="")),
- #ScreenshotConfig(SeedExportXpubQRDisplayView),
+ ScreenshotConfig(seed_views.SeedBackupView, dict(seed_num=0)),
+ ScreenshotConfig(seed_views.SeedExportXpubSigTypeView, dict(seed_num=0)),
+ ScreenshotConfig(seed_views.SeedExportXpubScriptTypeView, dict(seed_num=0, sig_type="msig")),
+ ScreenshotConfig(seed_views.SeedExportXpubCustomDerivationView, dict(seed_num=0, sig_type="ss", script_type="")),
+ ScreenshotConfig(seed_views.SeedExportXpubQRFormatView, dict(seed_num=0, sig_type="ss", script_type="nat")),
+ ScreenshotConfig(seed_views.SeedExportXpubWarningView, dict(seed_num=0, sig_type="msig", script_type="nes", xpub_qr_format="urca", custom_derivation="")),
+ ScreenshotConfig(seed_views.SeedExportXpubDetailsView, dict(seed_num=0, sig_type="ss", script_type="nat", xpub_qr_format="urca", custom_derivation="")),
+ ScreenshotConfig(SeedExportXpubQR_ScreenBrightnessView, dict(seed_num=0, xpub_qr_format="urca", derivation_path="m/84'/0'/0'")),
+
ScreenshotConfig(seed_views.SeedWordsWarningView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedWordsView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedWordsView, dict(seed_num=0, page_index=2), screenshot_name="SeedWordsView_2"),
- ScreenshotConfig(seed_views.SeedBIP85ApplicationModeView, dict(seed_num=0)),
- ScreenshotConfig(seed_views.SeedBIP85SelectChildIndexView, dict(seed_num=0, num_words=24)),
- ScreenshotConfig(seed_views.SeedBIP85InvalidChildIndexView, dict(seed_num=0, num_words=12)),
- ScreenshotConfig(seed_views.SeedWordsBackupTestPromptView, dict(seed_num=0)),
- ScreenshotConfig(seed_views.SeedWordsBackupTestView, dict(seed_num=0, rand_seed=6102)),
- ScreenshotConfig(seed_views.SeedWordsBackupTestMistakeView, dict(seed_num=0, cur_index=7, wrong_word="satoshi")),
- ScreenshotConfig(seed_views.SeedWordsBackupTestSuccessView, dict(seed_num=0)),
- ScreenshotConfig(seed_views.SeedTranscribeSeedQRFormatView, dict(seed_num=0)),
+ ScreenshotConfig(seed_views.SeedBIP85SelectNumWordsView, dict(seed_num=0)),
+ ScreenshotConfig(seed_views.SeedBIP85SelectChildIndexView, dict(seed_num=0, num_words=24)),
+ ScreenshotConfig(seed_views.SeedBIP85InvalidChildIndexView, dict(seed_num=0, num_words=12)),
+ ScreenshotConfig(seed_views.SeedWordsBackupTestPromptView, dict(seed_num=0)),
+ ScreenshotConfig(seed_views.SeedWordsBackupTestView, dict(seed_num=0, rand_seed=6102)),
+ ScreenshotConfig(seed_views.SeedWordsBackupTestMistakeView, dict(seed_num=0, cur_index=7, wrong_word="satoshi")),
+ ScreenshotConfig(seed_views.SeedWordsBackupTestSuccessView, dict(seed_num=0)),
+ ScreenshotConfig(seed_views.SeedTranscribeSeedQRFormatView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRWarningView, dict(seed_num=0)),
- ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=21), screenshot_name="SeedTranscribeSeedQRWholeQRView_12_Compact"),
- ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_12_Standard"),
- ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Compact"),
- ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__SEEDQR, num_modules=29), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Standard"),
- ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, initial_block_x=1, initial_block_y=1), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Compact"),
- ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, initial_block_x=2, initial_block_y=2), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Standard"),
+ ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=21), screenshot_name="SeedTranscribeSeedQRWholeQRView_12_Compact"),
+ ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_12_Standard"),
+ ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Compact"),
+ ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__SEEDQR, num_modules=29), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Standard"),
+ ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, initial_zone_x=1, initial_zone_y=1), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Compact"),
+ ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, initial_zone_x=2, initial_zone_y=2), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Standard"),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRConfirmQRPromptView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRConfirmWrongSeedView),
@@ -306,11 +388,11 @@ def generate_screenshots(locale):
ScreenshotConfig(seed_views.SeedSelectSeedView, dict(flow=Controller.FLOW__VERIFY_SINGLESIG_ADDR), screenshot_name="SeedSelectSeedView_address_verification"),
ScreenshotConfig(seed_views.AddressVerificationSigTypeView),
- ScreenshotConfig(seed_views.SeedAddressVerificationView, dict(seed_num=0), run_before=load_address_verification_data_cb),
- ScreenshotConfig(seed_views.SeedAddressVerificationSuccessView, dict(seed_num=0)), # Relies on callback above
+ ScreenshotConfig(seed_views.SeedAddressVerificationView, dict(seed_num=0), mock_context_manager=mock_address_verification_data_loaded),
+ ScreenshotConfig(seed_views.SeedAddressVerificationSuccessView, dict(seed_num=0), mock_context_manager=mock_address_verification_data_loaded),
ScreenshotConfig(seed_views.LoadMultisigWalletDescriptorView),
- ScreenshotConfig(seed_views.MultisigWalletDescriptorView, run_before=load_multisig_wallet_descriptor_cb),
+ ScreenshotConfig(seed_views.MultisigWalletDescriptorView, mock_context_manager=mock_multisig_wallet_descriptor_loaded),
ScreenshotConfig(seed_views.SeedDiscardView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedSelectSeedView, dict(flow=Controller.FLOW__SIGN_MESSAGE), screenshot_name="SeedSelectSeedView_sign_message"),
@@ -320,25 +402,27 @@ def generate_screenshots(locale):
ScreenshotConfig(seed_views.SeedElectrumMnemonicStartView),
],
"PSBT Views": [
- ScreenshotConfig(psbt_views.PSBTSelectSeedView, run_before=PSBTSelectSeedView_cb_before),
- ScreenshotConfig(psbt_views.PSBTOverviewView, run_before=load_basic_psbt_cb),
+ ScreenshotConfig(psbt_views.PSBTSelectSeedView, mock_context_manager=mock_controller_psbt_seed_empty),
+ ScreenshotConfig(psbt_views.PSBTOverviewView, mock_context_manager=mock_multisig_psbt_loaded),
ScreenshotConfig(psbt_views.PSBTUnsupportedScriptTypeWarningView),
ScreenshotConfig(psbt_views.PSBTNoChangeWarningView),
- ScreenshotConfig(psbt_views.PSBTMathView),
- ScreenshotConfig(psbt_views.PSBTAddressDetailsView, dict(address_num=0)),
+ ScreenshotConfig(psbt_views.PSBTMathView, mock_context_manager=mock_multisig_psbt_loaded),
+ ScreenshotConfig(psbt_views.PSBTAddressDetailsView, dict(address_num=0), mock_context_manager=mock_multisig_psbt_loaded),
- ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_multisig_unverified", run_before=load_basic_psbt_cb),
- ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_multisig_verified", run_before=load_multisig_wallet_descriptor_cb),
- ScreenshotConfig(psbt_views.PSBTOverviewView, screenshot_name="PSBTOverviewView_op_return", run_before=PSBTOverviewView_op_return_cb_before),
- ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_text"), # Relies on callback above
- ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_raw_hex_data", run_before=PSBTOpReturnView_raw_hex_data_cb_before),
+ ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_single_sig_change_verified", mock_context_manager=mock_single_sig_psbt_loaded),
+ ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=1), screenshot_name="PSBTChangeDetailsView_single_sig_self_transfer_verified", mock_context_manager=mock_single_sig_psbt_loaded),
+ ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_multisig_unverified", mock_context_manager=mock_multisig_psbt_loaded),
+ ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_multisig_verified", mock_context_manager=mock_multisig_psbt_and_descriptor_loaded),
+ ScreenshotConfig(psbt_views.PSBTOverviewView, screenshot_name="PSBTOverviewView_op_return", mock_context_manager=mock_psbt_with_op_return_loaded),
+ ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_text", mock_context_manager=mock_psbt_with_op_return_loaded),
+ ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_raw_hex_data", mock_context_manager=mock_psbt_with_op_return_raw_bytes_loaded),
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=False), screenshot_name="PSBTAddressVerificationFailedView_singlesig_change"),
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=False), screenshot_name="PSBTAddressVerificationFailedView_singlesig_selftransfer"),
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=True), screenshot_name="PSBTAddressVerificationFailedView_multisig_change"),
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=True), screenshot_name="PSBTAddressVerificationFailedView_multisig_selftransfer"),
- ScreenshotConfig(psbt_views.PSBTFinalizeView),
+ ScreenshotConfig(psbt_views.PSBTFinalizeView, mock_context_manager=mock_multisig_psbt_loaded),
#ScreenshotConfig(PSBTSignedQRDisplayViewScreenshotConfig),
- ScreenshotConfig(psbt_views.PSBTSigningErrorView),
+ ScreenshotConfig(psbt_views.PSBTSigningErrorView, mock_context_manager=mock_multisig_psbt_loaded),
],
"Tools Views": [
ScreenshotConfig(tools_views.ToolsMenuView),
@@ -354,21 +438,23 @@ def generate_screenshots(locale):
ScreenshotConfig(tools_views.ToolsCalcFinalWordShowFinalWordView, dict(coin_flips="0010101"), screenshot_name="ToolsCalcFinalWordShowFinalWordView_coin_flips"),
ScreenshotConfig(tools_views.ToolsCalcFinalWordDoneView),
ScreenshotConfig(tools_views.ToolsAddressExplorerSelectSourceView),
- ScreenshotConfig(tools_views.ToolsAddressExplorerAddressTypeView),
+ ScreenshotConfig(tools_views.ToolsAddressExplorerAddressTypeView, mock_context_manager=mock_multisig_wallet_descriptor_loaded),
ScreenshotConfig(tools_views.ToolsAddressExplorerAddressListView),
# ScreenshotConfig(tools_views.ToolsAddressExplorerAddressView),
],
"Settings Views": settings_views_list + [
ScreenshotConfig(settings_views.IOTestView),
ScreenshotConfig(settings_views.DonateView),
- ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), screenshot_name="SettingsIngestSettingsQRView_persistent"),
+ ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), screenshot_name="SettingsIngestSettingsQRView_persistent"),
ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), screenshot_name="SettingsIngestSettingsQRView_not_persistent"),
+ ScreenshotConfig(settings_views.SettingsSelectionRequiredWarningView, dict(attr_name=SettingsConstants.SETTING__SCRIPT_TYPES)),
],
"Misc Error Views": [
ScreenshotConfig(NotYetImplementedView),
ScreenshotConfig(UnhandledExceptionView, dict(error=["IndexError", "line 1, in some_buggy_code.py", "list index out of range"])),
+ ScreenshotConfig(CameraConnectionErrorView),
ScreenshotConfig(NetworkMismatchErrorView, dict(derivation_path="m/84'/1'/0'")),
- ScreenshotConfig(OptionDisabledView, dict(settings_attr=SettingsConstants.SETTING__MESSAGE_SIGNING)),
+ ScreenshotConfig(OptionDisabledView, dict(settings_attr=SettingsConstants.SETTING__MESSAGE_SIGNING)),
ScreenshotConfig(scan_views.ScanInvalidQRTypeView)
]
}
@@ -389,8 +475,18 @@ def generate_screenshots(locale):
try:
print(f"Running {screenshot_config.screenshot_name}")
try:
- screenshot_config.run_callback_before()
- screenshot_config.View_cls(**screenshot_config.view_kwargs).run()
+ cur_count = screenshot_renderer.render_count
+
+ # Activate the (optional) context manager for this screenshot to activate
+ # any specialized mocks.
+ with screenshot_config.mock_context_manager():
+ # Set up and run the target View
+ screenshot_config.View_cls(**screenshot_config.view_kwargs).run()
+
+ if screenshot_renderer.render_count == cur_count:
+ # The View didn't actually render anything
+ raise Exception(f"{screenshot_config.screenshot_name} did not render a screenshot. Verify that its `run_screen()` is reachable by the screenshot generator.")
+
except ScreenshotComplete:
# The target View has run and its Screen has rendered what it needs to
if toast_thread is not None:
@@ -398,18 +494,10 @@ def generate_screenshots(locale):
controller.activate_toast(toast_thread)
while controller.toast_notification_thread.is_alive():
# Give the Toast a moment to complete its work
-
time.sleep(0.01)
- # TODO: Necessary now that the lock is in place?
- # Whenever possible, clean up toast thread HERE before killing the
- # main thread with ScreenshotComplete.
- toast_thread.stop()
- toast_thread.join()
- raise ScreenshotComplete()
- except ScreenshotComplete:
- # Slightly hacky way to exit ScreenshotRenderer as expected
- print(f"Completed {screenshot_config.screenshot_name}")
+ print(f"Completed {screenshot_config.screenshot_name}")
+
except Exception as e:
# Something else went wrong
from traceback import print_exc
@@ -420,15 +508,13 @@ def generate_screenshots(locale):
toast_thread.stop()
toast_thread.join()
- screenshot_config.run_callback_after()
-
# Parse the main `l10n/messages.pot` for overall stats
messages_source_path = os.path.join(pathlib.Path(__file__).parent.resolve().parent.resolve().parent.resolve(), "l10n", "messages.pot")
with open(messages_source_path, 'r') as messages_source_file:
num_source_messages = messages_source_file.read().count("msgid \"") - 1
- locale_tuple_list = [locale_tuple for locale_tuple in SettingsConstants.ALL_LOCALES if locale_tuple[0] == locale]
+ locale_tuple_list = [locale_tuple for locale_tuple in SettingsConstants.get_detected_languages() if locale_tuple[0] == locale]
if not locale_tuple_list:
raise Exception(f"Invalid locale: {locale}")
@@ -478,8 +564,10 @@ def generate_screenshots(locale):
with open(os.path.join("tests", "screenshot_generator", "template.md"), 'r') as readme_template:
main_readme = readme_template.read()
- for locale, display_name in SettingsConstants.ALL_LOCALES:
+ for locale, display_name in SettingsConstants.get_detected_languages():
main_readme += f"* [{display_name}]({locale}/README.md)\n"
with open(os.path.join(screenshot_root, "README.md"), 'w') as readme_file:
readme_file.write(main_readme)
+
+ print(f"Screenshots rendered: {screenshot_renderer.render_count}")
diff --git a/tests/screenshot_generator/utils.py b/tests/screenshot_generator/utils.py
index d767bdf2..83581f4b 100644
--- a/tests/screenshot_generator/utils.py
+++ b/tests/screenshot_generator/utils.py
@@ -1,5 +1,5 @@
import os
-
+from contextlib import contextmanager
from dataclasses import dataclass
from PIL import Image, ImageDraw
@@ -10,6 +10,10 @@ from seedsigner.views.view import View
class ScreenshotComplete(Exception):
+ """
+ Slightly hacky way for the ScreenshotRenderer to intentionally break out of the
+ normal Controller flow in order to return control to the screenshot generator.
+ """
pass
@@ -18,6 +22,10 @@ class ScreenshotRenderer(Renderer):
screenshot_path: str = None
screenshot_filename: str = None
+ @property
+ def is_screenshot_generator(self) -> bool:
+ return True
+
@classmethod
def configure_instance(cls):
# Instantiate the one and only Renderer instance
@@ -30,11 +38,13 @@ class ScreenshotRenderer(Renderer):
renderer.canvas = Image.new('RGB', (renderer.canvas_width, renderer.canvas_height))
renderer.draw = ImageDraw.Draw(renderer.canvas)
-
+
+ renderer.render_count = 0
+
def set_screenshot_filename(self, filename:str):
self.screenshot_filename = filename
-
+
def set_screenshot_path(self, path):
if not os.path.exists(path):
@@ -56,18 +66,33 @@ class ScreenshotRenderer(Renderer):
self.canvas.paste(image)
self.canvas.save(os.path.join(self.screenshot_path, self.screenshot_filename))
+ self.render_count += 1
+
+ # Break out of the normal Controller flow and return to the screenshot generator
raise ScreenshotComplete()
+@contextmanager
+def default_mock_context_manager():
+ # Just a no-op context manager
+ yield
+
+
+
@dataclass
class ScreenshotConfig:
+ """
+ - mock_context_manager: Option to provide mocks to set up custom data or state that
+ the screenshot might need. The mocks will only be active during this one
+ screenshot's generation. Ensures that there are no persistent state changes left
+ over that might affect other screenshots.
+ """
View_cls: View
view_kwargs: dict = None
screenshot_name: str = None
toast_thread: BaseToastOverlayManagerThread = None
- run_before: callable = None
- run_after: callable = None
+ mock_context_manager: callable = default_mock_context_manager
def __post_init__(self):
@@ -75,13 +100,3 @@ class ScreenshotConfig:
self.view_kwargs = {}
if not self.screenshot_name:
self.screenshot_name = self.View_cls.__name__
-
-
- def run_callback_before(self):
- if self.run_before:
- self.run_before()
-
-
- def run_callback_after(self):
- if self.run_after:
- self.run_after()
diff --git a/tests/test_controller.py b/tests/test_controller.py
index d01a54cf..f414dc89 100644
--- a/tests/test_controller.py
+++ b/tests/test_controller.py
@@ -4,7 +4,6 @@ import pytest
from base import BaseTest
from seedsigner.controller import Controller
-from seedsigner.models.settings_definition import SettingsConstants
class TestController(BaseTest):
@@ -80,37 +79,3 @@ class TestController(BaseTest):
# ...get a new copy of the instance and confirm change
controller = Controller.get_instance()
assert controller.unverified_address == "123abc"
-
-
- def test_missing_settings_get_defaults(self):
- """ Should gracefully handle all missing fields from `settings.json` """
-
- controller = Controller.get_instance()
-
- # Settings defaults
- assert controller.settings.get_value(SettingsConstants.SETTING__LOCALE) == SettingsConstants.LOCALE__ENGLISH
- assert controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH
- assert controller.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED
- assert controller.settings.get_value(SettingsConstants.SETTING__COORDINATORS) == [i for i,j in SettingsConstants.ALL_COORDINATORS if i!="kpr"]
- assert controller.settings.get_value(SettingsConstants.SETTING__BTC_DENOMINATION) == SettingsConstants.BTC_DENOMINATION__THRESHOLD
-
- # Advanced Settings defaults
- assert controller.settings.get_value(SettingsConstants.SETTING__NETWORK) == SettingsConstants.MAINNET
- assert controller.settings.get_value(SettingsConstants.SETTING__QR_DENSITY) == SettingsConstants.DENSITY__MEDIUM
- assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED
- assert controller.settings.get_value(SettingsConstants.SETTING__SIG_TYPES) == [i for i,j in SettingsConstants.ALL_SIG_TYPES]
- assert controller.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES) == [SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT, SettingsConstants.TAPROOT]
- assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_DETAILS) == SettingsConstants.OPTION__ENABLED
- assert controller.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__ENABLED
- assert controller.settings.get_value(SettingsConstants.SETTING__CAMERA_ROTATION) == SettingsConstants.CAMERA_ROTATION__180
- assert controller.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__ENABLED
- assert controller.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__DISABLED
- assert controller.settings.get_value(SettingsConstants.SETTING__MESSAGE_SIGNING) == SettingsConstants.OPTION__DISABLED
- assert controller.settings.get_value(SettingsConstants.SETTING__PRIVACY_WARNINGS) == SettingsConstants.OPTION__ENABLED
- assert controller.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED
- assert controller.settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS_TIPS) == SettingsConstants.OPTION__ENABLED
- assert controller.settings.get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED
-
- # Hidden Settings defaults
- assert controller.settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS) == 62
-
diff --git a/tests/test_decodepsbtqr.py b/tests/test_decodepsbtqr.py
index 7672d62b..3c86b6a0 100644
--- a/tests/test_decodepsbtqr.py
+++ b/tests/test_decodepsbtqr.py
@@ -409,6 +409,7 @@ def test_seed_qr():
assert d.get_seed_phrase() == "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash".split()
+
def test_specter_wallet_json():
parts = [
'p1of3 {"label": "SeedSigner Dev Funds", "blockheight": 692143, "descriptor": "wsh(sortedmulti(4,[e0811b6b/48h/0h/0h/2h]xpub6E8v7uy63pCeJvHe5W8ea8zTnCtKMFgMRb5bueWWcUFMw6sWmUwTqxM8cFiKQRWkA2Fxth9HJZufJwjWTTvU1UGZNpTrh9khrswYMgeHiCt/0/*,[852b308f/48h/0h/0h/2h]xpub6ErhgAWfnEqW7xDBm1iLq5JjNyUS65YUFnjHLrRv9zmdDEtuE75bpWQ8o6bSBnpT6AkrrsA8eA5SmEFArZn11KEPaZJzx9mHTXPWZCsxLyh/0/*,[7edf9c59/48h/0h/0h/2h]xpub6DaFfKoe7Wpofr'
@@ -426,6 +427,7 @@ def test_specter_wallet_json():
assert d.get_wallet_descriptor() == "wsh(sortedmulti(4,[e0811b6b/48h/0h/0h/2h]xpub6E8v7uy63pCeJvHe5W8ea8zTnCtKMFgMRb5bueWWcUFMw6sWmUwTqxM8cFiKQRWkA2Fxth9HJZufJwjWTTvU1UGZNpTrh9khrswYMgeHiCt/0/*,[852b308f/48h/0h/0h/2h]xpub6ErhgAWfnEqW7xDBm1iLq5JjNyUS65YUFnjHLrRv9zmdDEtuE75bpWQ8o6bSBnpT6AkrrsA8eA5SmEFArZn11KEPaZJzx9mHTXPWZCsxLyh/0/*,[7edf9c59/48h/0h/0h/2h]xpub6DaFfKoe7WpofrbYeNo3Wv2AiLUMeyrPwotXfukFxUHbK4JxaLHTd5394QtH5wnjFzBgr2YnJpHhXv25Zsqv2APmMFvH1DsKHj5LCr3pmXs/0/*,[b433e095/48h/0h/0h/2h]xpub6EF51itHko2YhGTjVeuYbBgJjVbTzzpYzn2a3JwZHpDrMePRVgXGBHMx2Yv1KwgLsUn9i7ExcAo8uqMx4pDjVRY9J7qnceFAwRRj16dd5AS/0/*,[184d07eb/48h/0h/0h/2h]xpub6EEoTpcQu7N4R8D84pJjZ69j3minevnYLDDoo2HBzYBXTQ4rGVf4XGTyCYFwJuZdsF9MyFYJNzYEjg5LGMA1ubTGWuDnjHAZz6ficVRDTSy/0/*,[3e451efe/48h/0h/0h/2h]xpub6ExQPvQxGBMaPxr8Fv7Vq91ztJFFX3VWvtpvex6UPZ1AptTeuAiJGCtKkgwJkrwpMZMagh9ex6rL4sM8axfFcdQbERoFCRUKTJxrBkJh56g/0/*))#c44hel9e"
+
def test_specter_wallet_json2():
part = '{"label": "Testnet Single Zone", "blockheight": 2090512, "descriptor": "wpkh([990a73ad/84h/1h/0h]tpubDDHQMDnFdan2GyHBsG32VW9qiygbhVizGRTjiS3H79M49FSvpsvLXqLgp1yC7r43dXVHozWavi2Fc4WHUpZmQYmzoQbit28qJhLjScbAQWU/0/*)#ujr0xunp","devices": [{"type": "seedsigner", "label": "Single Seed Zone Testnet"}]}'
@@ -434,3 +436,96 @@ def test_specter_wallet_json2():
assert d.qr_type == QRType.WALLET__SPECTER
assert d.is_complete
+
+
+
+def test_basic_1_in_2_out_bbqr_psbt_qr():
+ parts = [
+ 'B$ZP0100FMUE4KXZZ7EHBEUJQGAYDGMXLP2O34WEVGERCKNOQWTY3URDYXHGTXTTHOVHKW6YXZQYEVSN6UGQMEHYB4CP4RI3BJK43MVLSZ7ZRH5R5SB4527A5C6N3LL65GZRPNZZGU2NK332BFLVR7VVKSYTCILLOLCJZ5PGFQGCC2U37YW5J6CTXMW6NHMT533MMPJ3YXQQVGL2ZZ76AADRETFVNCV4MGOMY6GEPPPG5XSZFIOQ6PW2JUFQVDWX5FMIHLHCI3Y23V5WGPPK5OF5GSFOKZI576FTVA5FGM5NNACNMNSAQBLZQZY62PJQS7Q72PQGVTABGZRVEPB4HL5MGCB3SVONS5P7W7BGVVT557AFA33L63OW43GW5BAYVTCMNLH5XCUEDNRF4HXFFLMHCWV25FZM6VQNQFPW2PZWI5MXP3QKHGIKOPMXR6CZGOO6CFSQ42IKN3USTQ44OHX53YYXTSSX74XXZ7ZPFXMRMCDTBDJU3JIJ2Z6ZXJXOK3LD6YHA3NYV4PDUDFR2K2H6RIPZ4BI7V2YOXRXPQY5YJELSQ4EHNXBM6HQX74PC26NXEXI6B7UR6IQMH446CDUR36RHYRKV5DE5ZUUVS5FEIWNKJLGJXHGPK66WGP5YNX3PEW24ZRLTJYAOMEBNXIGEYT2OSSUTLGKFZX7M7VTZUGI5LH5GM6X6PC76XZOO3PM7WV67BCM3D5M3XMAEQBYPJAWRAJZQAMEQA'
+ ]
+
+ d = DecodeQR()
+ for i in parts:
+ if d.add_data(i) == DecodeQRStatus.COMPLETE:
+ break
+ assert d.qr_type == QRType.PSBT__BBQR
+
+ #complete should be true
+ assert d.is_complete
+
+
+
+def test_basic_1_in_2_out_bbqr_non_compressed_psbt_qr():
+ parts = [
+ 'B$2P0200OBZWE5H7AEAMGAQAAAAAGLZDYX3EGIZDZMNR5DQRFVCQAIG3FL5BAHWRJ3PGCJ3NTFFLVFRKBIAAAAAA7X77776SNCAHN5R4BCQGWFWOT7MQUMN7IYDICAIMVZOQWEMKWXPVVJWTZ4AAAAAAAD6777775FCZSAY26LQG5V3DG7J4Y6HFZSAL65OCPEMYB6SLACVNXVAAIOPAAAAAAAAP377774BBAJYAAAAAAAAACYABITRMR72K5TBKF5JQHFEP3N7MTN6BSTVZO6AAAAAAAAAAAALAAFH572746E5HEAFOBJOKAWW3AZSTJ5YGQ4IAAAAAATYBAQ2YPTYD3PPOOG4AAAAABMZKKMFX7N7X3N7UFH3DJ6TPIJJNKDDEZ2V5WXVEFF7ED2KPRLQWAJQJYJPKMVHIF3B567MHOVVTZEREDZ3JU5K7433BO4PCIWIAXMMKCEDF7NB74VAAACAACAAAQAAAAAEAAAAQB7MIAEBAAAAAAFYISWF3KWA2BQJIWFBLOAQUC75FZ7U663FLHY2UPMSMBIORDQQ4ICAAAAAAB7P7777QWEBHAAAAAAAAAALAAFDLBORR4YUXZXUT2AMGPV5STUNSAWXZC3AQE4AAAAAAAAABMAAUGQY57O3ZSGKZQPJIVDDRHCZR26EBKEXBCATQAAAAAAAAAFQACRQDOY52JHJTNRMSBEU3ZNF7MU3CQ67OE7UAGAAAAAAAAAAWAAKHTOIMMYL4F27RNYMKHR27HEACX2GC5OBRAJYAAAAAAAAACYABIUXQGIMLE6T4JV4HB7DBKMD2ZSOAJ3BNWEBHAAAAAAAAAALAAFG6U2YBVVC5EOTPROOXWHTT3NQNDN24INYQE4AAAAAAAAABMAAUCUT7GZTM564L2AHZT2WWKSNMW2XN6SRTTQZSWAAAAAAAAFQACSIVWLYCFXTXUOPJHVTG424NY2MHVFAUJ4ICOAAAAAAAAAAWAAKO5BX4TBBZOLBTGINJPGZXHCHXLSFXOGFRAJYAAAAAAAAACYABJ6E4FYLPW652VRHZ2TXZ',
+ 'B$2P0201MH36EIIPZFAIMEBHAAAAAAAAAALAAFGZMA575IOQ64JRLZ7FDFP5HXDSI7T5TCB3K4TQAAIBD4ICOAAAAAAAAAAWAAKNSYB372Q5B5YTCXT6KGK72POHER7H3GECEBQDUQRHUJOBI3ZLGB5D45DTXHXDO2KR3SYOVVK7B3IWBLBTMZOLAWMBQZP3IP7FIAAAQAAQAAEAAAAABAAAAAAAALAAAAAAAAIAOEBAAAAAAGYGM76R4MFE4PVHWKO7UMETKM7CMCYSGXHLZBKFSTV77SGMMGAIWAIAAAAAB7P7777QF2ADAAAAAAAAAALAAFDVHQFTAZIUYBERQK72U7UJXL4TP4IATCF7VQXQAAAAAAABMAAUJKWTGIZ24GA6VEFIYS3FJBDRMPCZEAN75DGSKAABAEP6QAYAAAAAAAAACYABI5J4BMYGKFGAJEMCX6VH5CN27E37CAEYQIQGALL3CUCJCC5XCJYUJJZQTXXO3YZORCQGK4GZNW3IGGPLOVQF2VCBEGDF7NB74VAAACAACAAAQAAAAAEAAAAAAAAEAAAAAAABABJAEAAAAAA7YEPDL6LPKO5S4PGHT7RSZD7WRGO3JUDPI2L2LPGRPBZ3ORRB5NYAAAAAAAH577776ALCA4AAAAAAAAABMAAUXRNOMGZL4W3EIBN7B33H6UPCYV3NJF32PVLCOAABAEPWEBYAAAAAAAAACYABJPC24YNSXZNWIQC36DXWP5I6FRLW2SLXUIQGALPV276UOSFQ4GYKIORFRCTTGOSCPBCJ7QWFFL2K4UCZDA27YOVQGGDF7NB74VAAACAACAAAQAAAAAEAAAAAAAA6AAAAAABCAIB2VZDSEABFIYX7GUZUJM27QMBDRYRRCHKG6SGYIID7ZDZIBMWDLLAYMX5UH7SUAAAIAAIAACAAAAAAQAAAAAAAGUAAAAAAEIBAENBW7O5JKFANLCOJJSIFIZLZ2JPIDZRECITJNP2QDDZACAOE57MRDBS7WQ76KQAABAABAAAIAAAAACAACAAAAAIQAAAAAA',
+ ]
+
+ d = DecodeQR()
+ for i in parts:
+ if d.add_data(i) == DecodeQRStatus.COMPLETE:
+ break
+ assert d.qr_type == QRType.PSBT__BBQR
+
+ #complete should be true
+ assert d.is_complete
+
+
+
+def test_large_multipart_bbqr_psbt_qr():
+ parts = [
+ 'B$ZP04003WJHSUCTQ4PIK3YSIJKDNJLBCXBGKDICWLEI4D4CBWEBAEIEEJFSCLFSDACSVGGKEJBAQWCBWAUAEATBRHAFHCXMKEQBDGVAMKSFIZBJFCSWAUMUPUVSRMR77N6H7NBTZROGMLDKL5TH5E3E4YTHHPXPTSJ2O7BINMYCAEIOAAAAZH3LXF52M3I63NSBGPULBVT34BA7XQIKWNHBOV7D37BSCX6WIKYV63XCCYDFNVWQ37VDXBIMUH27CQHIGNTLS44FRYBV7QW32CULRZGLY5WO5ZXO6NJRFHY2VCG5IHZ3BJ6RYABDZ7L7R47JFBP5UBXVR46NCVK3OLSXIGCCGO7FUIPWFYLRUH4PDZPEOQAVTQJ7DYBWAMDVMDDXMJOY7OPYAJLROUZR2KBVLUU6H6DPT55OQDW55CXBZW5WARPNAPUQPH4J4BGVI6GGXH2KU42DFLE5NKJQQXX2EOISJVAFG4I5L4MMFYV44ZDWFPKYJS5QHRZGK63T53YJHEIJORCV5F5VFDU233LWQZIPDRORLJDLD7JC3IY2M4EBURPMKHSTNPXRMKXGTKVWSNMXOMCYKQUCR6QBZHYXWMWMUQBZHVQYYCZB3GT52KDFO6ZZHJXA3Y6ZMFOZ7UIQPHGYDKYW4J7VLVFECBJ4PJIHB7C3HRJPJXYXMLHIIZKSOG6HWENEGPLFKCICHNU7RCCOGZEHW7HGGXENTRV4OYJ32HKZEUZ6SJRBLO4TLF3YQVW3LNS4OHEPIBA3ZSXW6JZQM5UTQIM732LBENYKXYDDPMVI6AVN6I22JJTXYEDP23BANCYIKE3MPQ42AIBV5F3EDWGH53GCP5XGYSBC557I2FQJTF7EUQQEZYTOM4DJ2BBIUKCMEDTMUPQFZ4QIEWD4MJZBHIR36DXB336K57AWDDHKQSG3MDHIOCBRBO3YZXPTLZV4LJPL4ZVQ5HFCCHLJ6OBIJE6AJSXJFS7S6N7Y5MCHH6K32CDA6BBIF6OM7SVGUBJ46OT2YSCHGONHGJ4U2GBBYPUEZQDIKMMHRS5OV2WS6DWFTW4MQ5Z55JTR256REQT3S5FMMOSCVKDBJUMWMQINRUVZCCFAE2DBUTIX4Z4VTL4FM52OONLPKAFPPTIKFVSB42FTPMV3',
+ 'B$ZP0401R2NIJC6YGI5PXFPPSXIZIJ6F5MPABQ7JTUK3MABHDBG7WWGCED33GILBH3IMEZENRW77JOMKNVVTZNDY4XGRE5PH7EQFYQ4WTBWIJBVLV7CVL453J47I4EHIPX6D2QRWWCQDPWWCFPSRT5R27HHKS22TMGPIZZMDYLEWXUTPMXHAM7LRETD4ANY5OS4EDCC5XTTO6GTTLYWS6XZT3B2OLEEOWR4UDQSJMATFPSLF7G4WGTHBAJE33CR5U3LN4LKUZQ2VPU3KF3TQUCUFAQPUCOJ6F5TFS3CEDD2D4PRDMEXXYE3FUPKTQME6VJAXRXJRS7JR2YIK7VXXD4K2O6RWAAKXOMNRLSBAWCSXGIIUAXIMESFD7XHCUNPRVXXJZZVM5IAV56NAIWWYGDHUYKQPN4ZZBGJRPB5KS6CYJGQQSSYSECQTRC43ET6BGOECAFU7TDOIJ2AOXR54PO7SPNYBRIZ2MGRS2A6VBDKMJCJ5JXFBUACAQW6TXYH5AHXT4A7S4JIE27BYDNZ4RKFLVYXA3T46WXGXQOHHNUNHPXJHEG2HPKLEVQR2I2SICFW7TILMU2ERRSPV3GQHNDQEUL3GGPHEYWQ3NVQA5EAKVP6NOYQIEJHDWX76IAHRC4H7T5PNATTCYDJH4TJ5WUXCI3A5PYCGCYKV5ZL3IRF6TDAP4LGVUNT7X5IPYUD4LC5G5SH2DULRDITMR2DTKBIGXQXMSOQY7XMIL7643CIEJXX5DIWREMX5SSCAR7GTWNAXLW2XGNPLQPW3UOP4UXQEGB4CDQL4MZ7FLNICT34VGVVEUMM43OITZJUECDQ7IKDIL7GU3GQXUIYTKZM6XMOBERAPWVB2UWIWD3KUTNCPBUQ7RESKCLVYRGUI4Z3ULFYSNM23VETFZGBELD7VFP7N5SRZ3GIS2SKM4376WUWWRZ4T7IO35UVCXNBTKVZHWTKZSYQABQE72N74FPJHY4DOFGUGIL2XCAVZU3NHE57HTSA7OGUWNF7DBE63DSAY4N6OGHMGL4SBFGSLYEBW7HERFLABVNQYLELH2X7VNLYM24WN52CWDZPFSA3YXDAQYOCCQNXIEWSTKNMPBVLXYJNX4SDW5KYPNKOSYJRHYRYB23LRXC7F7SVC7PMCBPMR',
+ 'B$ZP0401R2NIJC6YGI5PXFPPSXIZIJ6F5MPABQ7JTUK3MABHDBG7WWGCED33GILBH3IMEZENRW77JOMKNVVTZNDY4XGRE5PH7EQFYQ4WTBWIJBVLV7CVL453J47I4EHIPX6D2QRWWCQDPWWCFPSRT5R27HHKS22TMGPIZZMDYLEWXUTPMXHAM7LRETD4ANY5OS4EDCC5XTTO6GTTLYWS6XZT3B2OLEEOWR4UDQSJMATFPSLF7G4WGTHBAJE33CR5U3LN4LKUZQ2VPU3KF3TQUCUFAQPUCOJ6F5TFS3CEDD2D4PRDMEXXYE3FUPKTQME6VJAXRXJRS7JR2YIK7VXXD4K2O6RWAAKXOMNRLSBAWCSXGIIUAXIMESFD7XHCUNPRVXXJZZVM5IAV56NAIWWYGDHUYKQPN4ZZBGJRPB5KS6CYJGQQSSYSECQTRC43ET6BGOECAFU7TDOIJ2AOXR54PO7SPNYBRIZ2MGRS2A6VBDKMJCJ5JXFBUACAQW6TXYH5AHXT4A7S4JIE27BYDNZ4RKFLVYXA3T46WXGXQOHHNUNHPXJHEG2HPKLEVQR2I2SICFW7TILMU2ERRSPV3GQHNDQEUL3GGPHEYWQ3NVQA5EAKVP6NOYQIEJHDWX76IAHRC4H7T5PNATTCYDJH4TJ5WUXCI3A5PYCGCYKV5ZL3IRF6TDAP4LGVUNT7X5IPYUD4LC5G5SH2DULRDITMR2DTKBIGXQXMSOQY7XMIL7643CIEJXX5DIWREMX5SSCAR7GTWNAXLW2XGNPLQPW3UOP4UXQEGB4CDQL4MZ7FLNICT34VGVVEUMM43OITZJUECDQ7IKDIL7GU3GQXUIYTKZM6XMOBERAPWVB2UWIWD3KUTNCPBUQ7RESKCLVYRGUI4Z3ULFYSNM23VETFZGBELD7VFP7N5SRZ3GIS2SKM4376WUWWRZ4T7IO35UVCXNBTKVZHWTKZSYQABQE72N74FPJHY4DOFGUGIL2XCAVZU3NHE57HTSA7OGUWNF7DBE63DSAY4N6OGHMGL4SBFGSLYEBW7HERFLABVNQYLELH2X7VNLYM24WN52CWDZPFSA3YXDAQYOCCQNXIEWSTKNMPBVLXYJNX4SDW5KYPNKOSYJRHYRYB23LRXC7F7SVC7PMCBPMR',
+ 'B$ZP0402CZV73DGFLBG3EGYTGIKV35X6GU4QSR7BE43JT5KS7C4DSBBW3CTBEH674AVXGNWAF65UGU65RJALJ5PUVX2KNTYJ6MXTTVFFIZT3FCJZRAG4LXJ3A52IYP7UFTRI2K5FGFJNVOYLK4NZC6ZZAZWMVQEJT7JAMXEN3ENQB6VM4L7N7WMFA35IPRMF6RH4ZALTUHBZI7U4GAN3CK4PQJZMWQTO5F5PZ7EV5R6QKCR27AMNN4H2GTXGJR2YM5CXOLMBSDZKHABOYMD46G2TWREHUOSNGQY3BJ6EBENF5DOF5VOXJF5Z7LN4LJRZR3MLNG4OPOMGNNIPANZB7JJJQ3OUVIU3R6FI2SD4HUINHY2ZOQ4F7ZDWIG6S7PHO6IZZNM3C7KAMAN2TW6CTLY44TAZ2DOQTXHTKOE6UEVHE7JVMJJ5PVTWBLWIRD6SCDXYPIXOXZGTVNGKGPY24F7L4OIFGLJIYF4ZGDFNFAEAFJWF3APPL777XEWHYEKVCAGERBXPKG7ZUYBTRLJE5BXOPHSYDGZ5QMBUJYJVWPB2G4V3I25AJUFAU7NTLHCJAZF4O2P5REH7MEBZVEMLCJX5H52HNRU5EXR7HP5VJORFCHQ62VE3ITYFFH4JESQS5OAJVCHOP5CZOETDGW5JE3OCIEBVD7RSDD3SSLJJVGWHQ2V34EW36JB3OVMHWVHJMEYT4I4A5NVY3RPS7ZKRPXIA5PTVH2Y7JXIQ6WO5AG7JGFLM7WEMKWSLWMNBKNIWL327SMJ7BFD6MIJVTLYFB6N4HWD4WYH62N5P426HB7JWG2KXR6EG34MTHAP6LEOZU2WWQQRC3J7PUU372TQYP7ESDLVVHJZVH54GYVTJUCER3SHQ3ABBYEUHLQXY26SHTYG42LIBQWHC4S473LH6JNLYUEUWH3OC6YY5AVGMJ47OHOXJ4H2ZVFMBHNVPSSKHG34DDHIM2G42OX4K5RYENJ4QPTWFJ2PWKK6ZIOUBVNTRE5F6ZZEDA3HUKK4CARMT3R73TBY73N6ESQBTU6BUZJOUGR367NVNNRZEPHNF5NQYGFFWKPPRMP4RQDCW3M4LBZKPTOJHM56KSX6B5GWYFJD7ETR3SFEANFZLMI6KVAPJZMSRUXHLQUQ2G5UWSSGRI',
+ 'B$ZP0402CZV73DGFLBG3EGYTGIKV35X6GU4QSR7BE43JT5KS7C4DSBBW3CTBEH674AVXGNWAF65UGU65RJALJ5PUVX2KNTYJ6MXTTVFFIZT3FCJZRAG4LXJ3A52IYP7UFTRI2K5FGFJNVOYLK4NZC6ZZAZWMVQEJT7JAMXEN3ENQB6VM4L7N7WMFA35IPRMF6RH4ZALTUHBZI7U4GAN3CK4PQJZMWQTO5F5PZ7EV5R6QKCR27AMNN4H2GTXGJR2YM5CXOLMBSDZKHABOYMD46G2TWREHUOSNGQY3BJ6EBENF5DOF5VOXJF5Z7LN4LJRZR3MLNG4OPOMGNNIPANZB7JJJQ3OUVIU3R6FI2SD4HUINHY2ZOQ4F7ZDWIG6S7PHO6IZZNM3C7KAMAN2TW6CTLY44TAZ2DOQTXHTKOE6UEVHE7JVMJJ5PVTWBLWIRD6SCDXYPIXOXZGTVNGKGPY24F7L4OIFGLJIYF4ZGDFNFAEAFJWF3APPL777XEWHYEKVCAGERBXPKG7ZUYBTRLJE5BXOPHSYDGZ5QMBUJYJVWPB2G4V3I25AJUFAU7NTLHCJAZF4O2P5REH7MEBZVEMLCJX5H52HNRU5EXR7HP5VJORFCHQ62VE3ITYFFH4JESQS5OAJVCHOP5CZOETDGW5JE3OCIEBVD7RSDD3SSLJJVGWHQ2V34EW36JB3OVMHWVHJMEYT4I4A5NVY3RPS7ZKRPXIA5PTVH2Y7JXIQ6WO5AG7JGFLM7WEMKWSLWMNBKNIWL327SMJ7BFD6MIJVTLYFB6N4HWD4WYH62N5P426HB7JWG2KXR6EG34MTHAP6LEOZU2WWQQRC3J7PUU372TQYP7ESDLVVHJZVH54GYVTJUCER3SHQ3ABBYEUHLQXY26SHTYG42LIBQWHC4S473LH6JNLYUEUWH3OC6YY5AVGMJ47OHOXJ4H2ZVFMBHNVPSSKHG34DDHIM2G42OX4K5RYENJ4QPTWFJ2PWKK6ZIOUBVNTRE5F6ZZEDA3HUKK4CARMT3R73TBY73N6ESQBTU6BUZJOUGR367NVNNRZEPHNF5NQYGFFWKPPRMP4RQDCW3M4LBZKPTOJHM56KSX6B5GWYFJD7ETR3SFEANFZLMI6KVAPJZMSRUXHLQUQ2G5UWSSGRI',
+ 'B$ZP0403GGGKSI5N46HND3DI3RFTMMIH6HI6EHTRJWJDZCBB3NGI63AFRYCSF5WNM5YYF3D2TV7W5USN7RXMS7KEOABUA7273S7SDTB7ZXXANY2CQUU72OCVN4SC47CDOY7ZXOHSFCULALBE2FNHTHDDLX3PYAJ25W53KKHOEWFMUMYDHNZODN3H44P5ZPAXZC7H3SL77GCXENXYTB4WB4HWDFBLXZE65ZPRAAQJVBUQPRQPWG2TZSVR3T426MYPK7OSYTFYY2HKLHA46CX2ZMG2EHU3HBHTWZAK4PVIS636I5OBTJUTVP3POGUK73T5DAKW2GH2KV4NLZIE6X4LT4SUJVCT2W4DGFGV5UTPR6EPOM5MF43L5VJT62QNPIDKN4P3QFND6JTPB6ZPLPGWDJUJDBEQ2AFAYLPB34H6AD4LXE2HIHWWN46Z3LABXII53DM3JBGBDNC6366NSSKW62DAZWPSN32LDOAESPZYOMPQ3RKGE2WLFVTHQONBAC5Z3G6XYKA3WGTUS6L5G35VIOVFSFQ62VE3ITYNEH4JESQS5OEJVCHGO5CZOETLGW5JEZOCIEFVD7RSDDZ24KNCI6IQIO3JSHWZBIOAWRHMTPH6EBGZ6U5P7XFETP4N3EX3RBQMGB7XXPMQ4REQMS6PNHY4ED7OEILCS3FHXYWH6IYBRLNWOFQ4VHZXETWO7FJL7A6TLMCUR7SJY5ZCODZ7P65B7R6TLY4F5N33YWAR45KQ2TC3FLI6IXRLBQM3LN5ENCQMIMFGR22J4O2GWORXCL5B3E7PK7VROTOZB5OYQD7QA'
+ ]
+
+ d = DecodeQR()
+ for i in parts:
+ if d.add_data(i) == DecodeQRStatus.COMPLETE:
+ break
+ assert d.qr_type == QRType.PSBT__BBQR
+
+ #complete should be true
+ assert d.is_complete
+
+
+
+def test_bbqr_psbt_p2wsh_qr():
+ base64_psbt = 'cHNidP8BAP0CAQIAAAAFjuDrZd3pwuA3kzEq4ttJVPKTWGrXxyc6cnl9hos4VngAAAAAAP3///8AqkNKKqmWfxFEtAdEjO/z/N7jRPK5innkNUncfkmUIgAAAAAA/f///zAtlcWU0S13udftJngWaBU4bjzVA23nCgoIch1ZlAvlAAAAAAD9////fVcrD2TAWw9lvIBZN+WRunW2WbYxvPUE/HUjH6PRHJAAAAAAAP3////VR/AP0SF1Nte2BVcs16GJsLcUY19YF0KhOMhideYsvQAAAAAA/f///wEvMQAAAAAAACIAIMNmMA1anHdE4e3+gSWGCzRXJ++WzkSw+lGXAUiWBf2x2R4NAE8BBIiyHgRSIO/ngAAAArm8DHyTW8t835/aMIRX4Jf13vxoh3IjDMcQfb+quwVnArq1fax/ivLnFVN8OdpnBLbQ5Xc+5IeXEtMQbVrO8Jk2FOCBG2swAACAAAAAgAAAAIACAACATwEEiLIeBLQlJwmAAAAC4IOLeQD9ojcPbh5QGsPVUt/g+dCiQrlZ1DvZK21ajf8CN4aND6VGGhYiFtI9NNyna/M03ovmM4PSg3nR7Df9jsoUhSswjzAAAIAAAACAAAAAgAIAAIBPAQSIsh4EBYK32oAAAAKj/K1oxxUQZkxFit/QBY3elqNbMNVMZYCL0TlR1Dh24gM63vretczqihVdcV7Qz7lHEEYdzPds4dGWq/KwyzySkBR+35xZMAAAgAAAAIAAAACAAgAAgE8BBIiyHgRgkAVVgAAAAsgLKl/ahhLHvS/3Cth+9Hde12MHJO5PP8REKtbWkqONAvETqIlMPWJ/f1uBvSCGFm+zzDYnnEBtuAYjZiQrzj9mFLQz4JUwAACAAAAAgAAAAIACAACATwEEiLIeBF/vBi6AAAAC70pZ3JxnIVQnyu7Z8xz1WHAUXHJNCRkLrKRhaXBj/mECAF2Dg2vDyBd4Tjp1HmB4Zb+D0PlGQmMp0AjqCoFSUYYUGE0H6zAAAIAAAACAAAAAgAIAAIBPAQSIsh4EwYVAaYAAAAKvbrl5PeuwgEBUqMQqBYTaTR+PUfKrOXzPQ87VbyLgXwMFEpYG8cv4ljYX+uebG0hJLXsD8K9Lc9K2RqaBmFOtyBQ+RR7+MAAAgAAAAIAAAACAAgAAgAABASvyDgAAAAAAACIAIG9pyc52xaQmtgmQkg5PnUEv4iEIlJ8mDPq4sTLrIxOqAQMEAQAAAAEFz1QhAh2ruKYkCPbQXSiJY75x3ZGJYFhAPoBeML9UUpPmqLUNIQM2Ep/T5OYXNoEg4eqh/ukxpatOIHqHqC8gdoetlYXyAiEDOQ+zV3EIG+LMOKwoUoVZaMgb9lpuVm89RYgonqZ/TokhA24fm1hnUcsqszTPU3GpHjX//nkz259/wcUSWv9w9pS/IQNw8GlQwL3kZE8zZDKmeoe2N1u8VgDgBaaXxnU6tA2NmCEDj3Y74jqomQwsR2bEL8w+MPqssKN1Whic9pySpeVJZ1ZWriIGAh2ruKYkCPbQXSiJY75x3ZGJYFhAPoBeML9UUpPmqLUNHOCBG2swAACAAAAAgAAAAIACAACAAAAAAO4EAAAiBgOPdjviOqiZDCxHZsQvzD4w+qywo3VaGJz2nJKl5UlnVhyFKzCPMAAAgAAAAIAAAACAAgAAgAAAAADuBAAAIgYDcPBpUMC95GRPM2QypnqHtjdbvFYA4AWml8Z1OrQNjZgcft+cWTAAAIAAAACAAAAAgAIAAIAAAAAA7gQAACIGAzYSn9Pk5hc2gSDh6qH+6TGlq04geoeoLyB2h62VhfICHLQz4JUwAACAAAAAgAAAAIACAACAAAAAAO4EAAAiBgNuH5tYZ1HLKrM0z1NxqR41//55M9uff8HFElr/cPaUvxwYTQfrMAAAgAAAAIAAAACAAgAAgAAAAADuBAAAIgYDOQ+zV3EIG+LMOKwoUoVZaMgb9lpuVm89RYgonqZ/TokcPkUe/jAAAIAAAACAAAAAgAIAAIAAAAAA7gQAAAABAStWBQAAAAAAACIAIFhpejaOEb6H3KY15a9sFpiF/lR7tn9e8ftMkPhJL9aSAQMEAQAAAAEFz1QhAhS3HJgXCjh7bpG2FdjJMeZbg4CIx5bTWzoM150MqoJwIQJEVv1riBsYs6K4omVZhD2TzIZR0HFKeuhXcrCXhPIfwiECnUMZ/+s9ILdahHdf5oYqLCln4We98usl56b8UPXt0/MhAyo1JcdOMv+VxUdrxcc5Eg+Pii8cpFrDWLVhulJtcRfxIQM25pzICIBWJTW+q7ZXpiysWxfT+/ZXSgObVmxskJL6CyEDW+Ouf7K/CShiZ0ZD9vcQM497ygaYa0KhAvejDVScNhpWriIGAhS3HJgXCjh7bpG2FdjJMeZbg4CIx5bTWzoM150MqoJwHOCBG2swAACAAAAAgAAAAIACAACAAAAAAN8CAAAiBgJEVv1riBsYs6K4omVZhD2TzIZR0HFKeuhXcrCXhPIfwhyFKzCPMAAAgAAAAIAAAACAAgAAgAAAAADfAgAAIgYCnUMZ/+s9ILdahHdf5oYqLCln4We98usl56b8UPXt0/Mcft+cWTAAAIAAAACAAAAAgAIAAIAAAAAA3wIAACIGAzbmnMgIgFYlNb6rtlemLKxbF9P79ldKA5tWbGyQkvoLHLQz4JUwAACAAAAAgAAAAIACAACAAAAAAN8CAAAiBgMqNSXHTjL/lcVHa8XHORIPj4ovHKRaw1i1YbpSbXEX8RwYTQfrMAAAgAAAAIAAAACAAgAAgAAAAADfAgAAIgYDW+Ouf7K/CShiZ0ZD9vcQM497ygaYa0KhAvejDVScNhocPkUe/jAAAIAAAACAAAAAgAIAAIAAAAAA3wIAAAABASsuEQAAAAAAACIAIIhTDjd0jQA6mGpfAeslKrvefJ3+i4Io/hjhgF6jkdX6AQMEAQAAAAEFz1QhAiB28mjDNER7/7BVSiNqmoIKhDxb60rrY95UEMLsEp1CIQJB36PmCTb1ERKuTDVsKFJZkoQfClnSp0k4XkwFN35VqSECvkBt2M0Xd+J2DvPGJOrxRMu013OCReqbdFIapuMQTwMhAykydOhAqBvndcArOzgFC4+bTMf9nYchwWOxmRZCUCxzIQNvCeqmQ7XS7QlqmQDD0TBWIk+tUfMryInDtIZ9tOGI2SEDk9RaHr9j4zqKxL4rgyF6DTlqcQ1dAo8vv1EbdjOlryRWriIGAykydOhAqBvndcArOzgFC4+bTMf9nYchwWOxmRZCUCxzHOCBG2swAACAAAAAgAAAAIACAACAAAAAANICAAAiBgNvCeqmQ7XS7QlqmQDD0TBWIk+tUfMryInDtIZ9tOGI2RyFKzCPMAAAgAAAAIAAAACAAgAAgAAAAADSAgAAIgYCIHbyaMM0RHv/sFVKI2qaggqEPFvrSutj3lQQwuwSnUIcft+cWTAAAIAAAACAAAAAgAIAAIAAAAAA0gIAACIGAr5AbdjNF3fidg7zxiTq8UTLtNdzgkXqm3RSGqbjEE8DHLQz4JUwAACAAAAAgAAAAIACAACAAAAAANICAAAiBgJB36PmCTb1ERKuTDVsKFJZkoQfClnSp0k4XkwFN35VqRwYTQfrMAAAgAAAAIAAAACAAgAAgAAAAADSAgAAIgYDk9RaHr9j4zqKxL4rgyF6DTlqcQ1dAo8vv1EbdjOlryQcPkUe/jAAAIAAAACAAAAAgAIAAIAAAAAA0gIAAAABASuEDgAAAAAAACIAIOB+54MYWx+3pQFzSOmMe0S2o1uugY8bsh0esZVHQlz7AQMEAQAAAAEFz1QhAgOHUaB2h2jJzW63IGIad5lUzqV4DZOHAt+yafsUKsjjIQI64fcLuvW14f8020fD60Ld/idDxao42hNpaon+PY2XhiEC2JAT6EDM+byCcYEank5iTBaJ9gutt1UBTyksg/bx9KIhAuIHT0ZAx9MbtiGKndvOY8PtMGOg/QQZNT+1rjZVs3i3IQNlA6szS8qo2CJkDIVFhAMMziBYPNcq+Kkm9KF6GEK+XiEDelvsJYUETArtTf/czMp3HDwa5ReuCx3Jf0nZ9FzWl+xWriIGAtiQE+hAzPm8gnGBGp5OYkwWifYLrbdVAU8pLIP28fSiHOCBG2swAACAAAAAgAAAAIACAACAAAAAAOUEAAAiBgLiB09GQMfTG7Yhip3bzmPD7TBjoP0EGTU/ta42VbN4txyFKzCPMAAAgAAAAIAAAACAAgAAgAAAAADlBAAAIgYCOuH3C7r1teH/NNtHw+tC3f4nQ8WqONoTaWqJ/j2Nl4Ycft+cWTAAAIAAAACAAAAAgAIAAIAAAAAA5QQAACIGA3pb7CWFBEwK7U3/3MzKdxw8GuUXrgsdyX9J2fRc1pfsHLQz4JUwAACAAAAAgAAAAIACAACAAAAAAOUEAAAiBgNlA6szS8qo2CJkDIVFhAMMziBYPNcq+Kkm9KF6GEK+XhwYTQfrMAAAgAAAAIAAAACAAgAAgAAAAADlBAAAIgYCA4dRoHaHaMnNbrcgYhp3mVTOpXgNk4cC37Jp+xQqyOMcPkUe/jAAAIAAAACAAAAAgAIAAIAAAAAA5QQAAAABASv7CgAAAAAAACIAIAhQMBN6JGVRSv1Xzw+w1nLmjuBOVXjk16Pv3ihZZCdbAQMEAQAAAAEFz1QhAgBfubsgNrZfsrCxmQOm9BuHvuGbywMUPEsjKLNU/yB5IQJoVoa8W1meWbr0dXXhlU1NBlIXaRt3eL97l6bHmPa9MyECla6WQ6nX2ihTWiYg0eVK2yYHx9WJtrlwmv0LR1fiALAhAqcf+7gV6Z7KudBH+4nR5qpAI1jRNUALyWQcCfIedCOnIQMZ475hd2XAzEQv9QuivapRsC4GZNLPKfpi8PfaVzhRwiED410JKiC6mPC7q6WEs2RqTnKqiDk0QDn+zYw6gZkVVI5WriIGApWulkOp19ooU1omINHlStsmB8fViba5cJr9C0dX4gCwHOCBG2swAACAAAAAgAAAAIACAACAAAAAAJ4CAAAiBgMZ475hd2XAzEQv9QuivapRsC4GZNLPKfpi8PfaVzhRwhyFKzCPMAAAgAAAAIAAAACAAgAAgAAAAACeAgAAIgYCpx/7uBXpnsq50Ef7idHmqkAjWNE1QAvJZBwJ8h50I6ccft+cWTAAAIAAAACAAAAAgAIAAIAAAAAAngIAACIGAgBfubsgNrZfsrCxmQOm9BuHvuGbywMUPEsjKLNU/yB5HLQz4JUwAACAAAAAgAAAAIACAACAAAAAAJ4CAAAiBgJoVoa8W1meWbr0dXXhlU1NBlIXaRt3eL97l6bHmPa9MxwYTQfrMAAAgAAAAIAAAACAAgAAgAAAAACeAgAAIgYD410JKiC6mPC7q6WEs2RqTnKqiDk0QDn+zYw6gZkVVI4cPkUe/jAAAIAAAACAAAAAgAIAAIAAAAAAngIAAAABAc9UIQJiWQ3c8kBNoEymcOTi0cR2SGzJ3sjrXAcCm08DMnopQiECmEq0nJmTkRJX3KELBCcOMlTMKJDCeeb2/l09whgKQFAhArNGIEsljNKi7uuZGyWlpQEYJy31TUGyrWdg+a9VKxFEIQLPwLYMxdaLRKCT+PRbDAuD53QdXYnaMGrTDN/Xkp2nGyEDgkMZ394Eapo4HgGldUUvJKOrHryc0rIcsW/sNeiSLhMhA4ra1ZeL4kkxj7icSUCn3UMFDNA6ev1NjjiwSb34WUmkVq4iAgOK2tWXi+JJMY+4nElAp91DBQzQOnr9TY44sEm9+FlJpBzggRtrMAAAgAAAAIAAAACAAgAAgAAAAAB4BQAAIgIDgkMZ394Eapo4HgGldUUvJKOrHryc0rIcsW/sNeiSLhMchSswjzAAAIAAAACAAAAAgAIAAIAAAAAAeAUAACICAphKtJyZk5ESV9yhCwQnDjJUzCiQwnnm9v5dPcIYCkBQHH7fnFkwAACAAAAAgAAAAIACAACAAAAAAHgFAAAiAgKzRiBLJYzSou7rmRslpaUBGCct9U1Bsq1nYPmvVSsRRBy0M+CVMAAAgAAAAIAAAACAAgAAgAAAAAB4BQAAIgICYlkN3PJATaBMpnDk4tHEdkhsyd7I61wHAptPAzJ6KUIcGE0H6zAAAIAAAACAAAAAgAIAAIAAAAAAeAUAACICAs/AtgzF1otEoJP49FsMC4PndB1didowatMM39eSnacbHD5FHv4wAACAAAAAgAAAAIACAACAAAAAAHgFAAAA'
+
+ parts = [
+ 'B$ZP05044YHEDDPOKBPHMMY3URG6GYXLQ5ZR5A4CLN3UXSKVLAYAQQIQ4BTBKU2LUWOKPXE663ZRWTRXGZTDOF3RY4CQINUEMTJJVM3G5OKCCIF5GRB7O2TPT7OF5W7VQTHDDQ4X5M4ZU67C5E255ZYWWD5NMI5IGIELQIRRP5F6R3PZI42R36XT6GO6HRJEFGNU4VIS62QTHHTLJL6JRVAVBBODWWT7EDAOK3RLLFUQNG27K5WFNNUJ3W42W7ODTTR6O3352YNGN5ZBODKI4S46OA56X445SLBKQCTHB4J57YRYJUKZFZXCSNJVVYOZIILKO7ZZIS5AVLGD6PAK6PJCLUB5NYLSD3Y2DMAOLF42I5OAHJSOLEMWAXVZB5L6USJGCHJS6NFQ5GNEDQU3Z7KTETRYWRRU57LSHHIP7R3R26RY2AZLTDYDZAYQLNYEN7IIDABMYPFNZ3FUGMBNN7MXWBWLFK5WLC2MVJO4B4NJ6XNU2O7C7SSN3IBQCFL2QTOTFJ526I7U4ZXDMQFAXUNCWINWPDDXSRPXPXP74ODKLQTKGICLJX5F6M2D2SZEL5GPWPSN4XAUMTQ4LNTRPX2H6TUOMGPMJXG3XAUDYAEN3NMDANWB5ZZUYTIYK7R3SWSAWSWENNZ3XSXRG6H3YV35SO5GSDIEE32PKZBGRYMGYTV6SVVUBOXSJIL4HU5BCW6GA7C2UPGKB3R3YXQPEERJADYRALFJIDGFFJIA4I2WASJN44I4XARFYXFCGVIJJOFJKR22E4WCSVQ6NFI3BJEYPHKH6AA',
+ 'B$ZP0503KUEKQJY5P37EIJDAYR75LV37UXPX2IJ7V3WD2GVR6NO44MLTGHNCRPZJBCARXNQ4PNBSVQSN7DOVL6CHAOBU3TRRNJPMRC4F5Y453TAW23M2G6AUAKHXBPND5FLHK7PA6LEEMNLWCKWSITNPTN6VK2EJN6WIYOKRHEOPOAQC2OPNVCRXHCGWUJJ4VBLYRBECPE2T27DY5WAGJJIGGOOL64XBBVU2MQ6EIGYA5O4W66AHIDKYQ7FXTRB2MEO3F4SIKXBDUZHFSGLAL24Q6V7KJETBDV3LCHCGVVNHCODUOIRFFFBKKFOYQPV25PW2SEBSXWAFUGU6RQVV6J36GNOV6Z677QPQ5QKYWOF75MLOB6PXQVITDTCQFMRN3IFAG6OTMKA4C4XXPFA7TFSRBDIBV7UGXU6VGNOMKB42TXZYVHZWUUKWW6UVRI3PRW5UP7FCOZJGMDAEHRHVT44G2T5KGNWCE5OOZPEJUOIXB7BM6Z5LKJO2OSBWFZHMS7UXYCAY4EYNKIZVO7R6QUCC33FTNJ6GB5CKPCYLNRVVLN2XSWVFSVQEKZB2AC2YUK6PWUORONEWPHXITVISATY3YFTGXL7466V6XJZPAWF6VVG3INQDB5N5JQ2JWEPXA5RUM77LUMAPYVVRGGIVF3F2Q6QQML5G6464W7GPOSEHQFKYQ5RWZP7WRB2FCWEHVQ6FEWNQB2L7HCEPQR24RSRD2UEOXEDVP4SA5MAOTF42NQ3Z5YKYPHD3RBVYFJK5ZQGBPLG37HFJ56MOT4PDFMIMPTOVX4DJICHOELCL7XBSDSVB',
+ 'B$ZP0503KUEKQJY5P37EIJDAYR75LV37UXPX2IJ7V3WD2GVR6NO44MLTGHNCRPZJBCARXNQ4PNBSVQSN7DOVL6CHAOBU3TRRNJPMRC4F5Y453TAW23M2G6AUAKHXBPND5FLHK7PA6LEEMNLWCKWSITNPTN6VK2EJN6WIYOKRHEOPOAQC2OPNVCRXHCGWUJJ4VBLYRBECPE2T27DY5WAGJJIGGOOL64XBBVU2MQ6EIGYA5O4W66AHIDKYQ7FXTRB2MEO3F4SIKXBDUZHFSGLAL24Q6V7KJETBDV3LCHCGVVNHCODUOIRFFFBKKFOYQPV25PW2SEBSXWAFUGU6RQVV6J36GNOV6Z677QPQ5QKYWOF75MLOB6PXQVITDTCQFMRN3IFAG6OTMKA4C4XXPFA7TFSRBDIBV7UGXU6VGNOMKB42TXZYVHZWUUKWW6UVRI3PRW5UP7FCOZJGMDAEHRHVT44G2T5KGNWCE5OOZPEJUOIXB7BM6Z5LKJO2OSBWFZHMS7UXYCAY4EYNKIZVO7R6QUCC33FTNJ6GB5CKPCYLNRVVLN2XSWVFSVQEKZB2AC2YUK6PWUORONEWPHXITVISATY3YFTGXL7466V6XJZPAWF6VVG3INQDB5N5JQ2JWEPXA5RUM77LUMAPYVVRGGIVF3F2Q6QQML5G6464W7GPOSEHQFKYQ5RWZP7WRB2FCWEHVQ6FEWNQB2L7HCEPQR24RSRD2UEOXEDVP4SA5MAOTF42NQ3Z5YKYPHD3RBVYFJK5ZQGBPLG37HFJ56MOT4PDFMIMPTOVX4DJICHOELCL7XBSDSVB',
+ 'B$ZP05044YHEDDPOKBPHMMY3URG6GYXLQ5ZR5A4CLN3UXSKVLAYAQQIQ4BTBKU2LUWOKPXE663ZRWTRXGZTDOF3RY4CQINUEMTJJVM3G5OKCCIF5GRB7O2TPT7OF5W7VQTHDDQ4X5M4ZU67C5E255ZYWWD5NMI5IGIELQIRRP5F6R3PZI42R36XT6GO6HRJEFGNU4VIS62QTHHTLJL6JRVAVBBODWWT7EDAOK3RLLFUQNG27K5WFNNUJ3W42W7ODTTR6O3352YNGN5ZBODKI4S46OA56X445SLBKQCTHB4J57YRYJUKZFZXCSNJVVYOZIILKO7ZZIS5AVLGD6PAK6PJCLUB5NYLSD3Y2DMAOLF42I5OAHJSOLEMWAXVZB5L6USJGCHJS6NFQ5GNEDQU3Z7KTETRYWRRU57LSHHIP7R3R26RY2AZLTDYDZAYQLNYEN7IIDABMYPFNZ3FUGMBNN7MXWBWLFK5WLC2MVJO4B4NJ6XNU2O7C7SSN3IBQCFL2QTOTFJ526I7U4ZXDMQFAXUNCWINWPDDXSRPXPXP74ODKLQTKGICLJX5F6M2D2SZEL5GPWPSN4XAUMTQ4LNTRPX2H6TUOMGPMJXG3XAUDYAEN3NMDANWB5ZZUYTIYK7R3SWSAWSWENNZ3XSXRG6H3YV35SO5GSDIEE32PKZBGRYMGYTV6SVVUBOXSJIL4HU5BCW6GA7C2UPGKB3R3YXQPEERJADYRALFJIDGFFJIA4I2WASJN44I4XARFYXFCGVIJJOFJKR22E4WCSVQ6NFI3BJEYPHKH6AA',
+ 'B$ZP05044YHEDDPOKBPHMMY3URG6GYXLQ5ZR5A4CLN3UXSKVLAYAQQIQ4BTBKU2LUWOKPXE663ZRWTRXGZTDOF3RY4CQINUEMTJJVM3G5OKCCIF5GRB7O2TPT7OF5W7VQTHDDQ4X5M4ZU67C5E255ZYWWD5NMI5IGIELQIRRP5F6R3PZI42R36XT6GO6HRJEFGNU4VIS62QTHHTLJL6JRVAVBBODWWT7EDAOK3RLLFUQNG27K5WFNNUJ3W42W7ODTTR6O3352YNGN5ZBODKI4S46OA56X445SLBKQCTHB4J57YRYJUKZFZXCSNJVVYOZIILKO7ZZIS5AVLGD6PAK6PJCLUB5NYLSD3Y2DMAOLF42I5OAHJSOLEMWAXVZB5L6USJGCHJS6NFQ5GNEDQU3Z7KTETRYWRRU57LSHHIP7R3R26RY2AZLTDYDZAYQLNYEN7IIDABMYPFNZ3FUGMBNN7MXWBWLFK5WLC2MVJO4B4NJ6XNU2O7C7SSN3IBQCFL2QTOTFJ526I7U4ZXDMQFAXUNCWINWPDDXSRPXPXP74ODKLQTKGICLJX5F6M2D2SZEL5GPWPSN4XAUMTQ4LNTRPX2H6TUOMGPMJXG3XAUDYAEN3NMDANWB5ZZUYTIYK7R3SWSAWSWENNZ3XSXRG6H3YV35SO5GSDIEE32PKZBGRYMGYTV6SVVUBOXSJIL4HU5BCW6GA7C2UPGKB3R3YXQPEERJADYRALFJIDGFFJIA4I2WASJN44I4XARFYXFCGVIJJOFJKR22E4WCSVQ6NFI3BJEYPHKH6AA',
+ 'B$ZP0500QXJHSNCUP4PQP4EZPOGW3CDEZNGMZNKEFBJXNP6UYRCGJCLMMNBFTMUVKD3MVPRHEJNWQIMTFDETEUUWJJ7FJMRGEVMUHJKVVCEHSTHGHD2TZXPHZY4XHTX46F647Z73PV7V5XSHDWHTDUFIAUAA3ICQFDGOTIKJS5LW73YPVGTW3FQ7PFUWAMMVM3R5DWZMVPSRW5BCEZI4GKQQYX6CYMAYBRKLDLVB7TKYZUGV4RFA54VJJ5PX6DUMSKTWUEUCL2VRV5E7GQ4CXOLSSC4DDPPJNTT4NAE23YH6WA4F3WCDJPFW62AIOJ5YXE4X3V2RZZRMOVQOT2YFNYDTXZF6K45JBWR2RD45XHWUPJ6QG7L45MH56SJZEKHIYST2YHHM2H74ZVYJ7GU7LURR22FL3F7CZOVALHI4NRCHILTJWQ4PVDJL22WRYRBLNX7PKGZFRERDIHRE6LNOMBUQQ47SZBWLYRQFLLDFH5S3JEOL43GDFUN3GIYAWN27RDZ2ETGRNRY6LYTM42CE6E3BFAKFAU6LOM6I36XY7DQIKPTCST2VBZWMYBH3OWC7FGPGN7QTBXCXOMFOYDW2UE6XIIJ2MJUE6YPNP3DTMWRXPDAFNR5R3GZJ2DGX7WFW5VZ3NGXAKA4M5E4IIIW73WPSO6U6EMGWSBFBSWJO42IKDSGEEDKEFPR2T6L7QHVRYYPPHQNBD4O7SORVSST77PCQHBWJXRAA2RRZF6AK6XCIIJMFXO2FL6VPPB7FKGKIDRLY53EKBTVHZL56OD5JCYFUAIYRSZQIE2RLVDZZUTQOO5NX',
+ 'B$ZP0501MYQ75A2GHMJQMOZQJEBRSBKUMKHZCS2YMKT2NWKTBX7RCUDLMBXKBKXVLWBJBHMPPVDXXDJ6X7PLVVTPQ6DDWM2KU3FB432NJUITYOMYJOARTMR7AVR4SHGSQKKXP2ELCFUK4U72Y367ZZDUQB626E4H6RD5H3IPZDZM7HVFCYSACX6WCTCRW2J3Q2DFFQ7LBAY4ERZKLLKWI42JQ5X3CSY5SRLGRW36KCYFE6JIDVTIRQZH6ZG4YIM7BQU73ONOSCC6ZI4PF67OEZ3MXQC7P6I2OOW4KXV3PTAN3W3J6EAIBMULRT2GY3ARBE2ND4QT34P6RUQQ3HYUGT6HNFBQPZZ35ZYHHMYYIFIWHDSJTAQ7PISJ53GCCN54NKBLIJ6LYJEBMRIP4QYVC7OGCLEWMUZFTLD5W5O3PKHEQDRZQAMICDHWF6H36Z5IRHGE3ZHAOYMWYDABH7P5Q7NUROXEOC6P3J532RLQ3NHPDRP7D2BEFIYVUZLK3AB44L7LBTN75HZHUOSXJLVCVU54DVFCGEHH3EF6W6PGN3O5YMRJWWTBQDNSUGLU5IRR5ULBBMFO4QLE3GTTW3BU4LW6VHPOPVMZ7D6TNJDNMH3MIBQL6O2PDIRKLYEFIE5Y2D6XUXCEFWODB7HO2LRNXTMFEUTIJYGYEISYE77M6X2OQKIAAI27SKVMPU4HCI5NDLL6JTFDFFXC3OCZLMX2WI62HPR6JTWTIJG6EIOQJMRMPRWV5SYXZBKK7NPJ7K7CVKGMKIHFS4LUF3ZVBHMDFPDHZ43EBIHXM73T257LLY5ZWKR3',
+ 'B$ZP0502N6QQLR6S2WU3KVVIEEGC3427H6WUVXVEFQEEZ5X7M5CKWKE3I5I77YADUXLG3RFZNNSQK7VWUK43HONJQVRQNLSWK2SZF3EIZ7AQ7BPDHT72PP5FZ5D3NJPO3AIYPYFDCWEMT7ZHR72AZ7G7XD7ZMRZKDP7TPSN74WIYVRX7BXUW66NEKXRP72UPSVTTVNGCVQ4LI4IPKY533OR6W2P2MOTW4OIHAVXUUJCQQPOU73F3YUOKO4B2KZ5JP44BJLGGM6EXA24EPCO2CCZ5P64HTHA2DELNPHGRJXK6F2ON4MUY54EGACZNLATHDRHLIIX7K27AWJKKHOWTLRVMYPDTB6ENWVXVSZWUSTCH2CGI2NO4XGGUEZKNWRQFHCGMPSBLNZYB25VNTONJJGNA7XODH45B7MVQJSVLZI2JW3DBUQ36S6ZWGVVQV4BFE4UOCL63G3NFIHNYNV7NQR7EZOJPN4O4C3HDAKYZ2X5BFORVJXYRUVNEPN7NNWQQZSYTU5BIEV2Q4C4B2TI5FUHSAYXDSR2XHXO5S374ZLESDD6YWM6HKLQB34FHQLLS2XCH4OKEPIHKZQ2BQC4QPRGHYI5GJZMRVKAHLOE4I6WADVRBFOHVINFMINLB5KLELCD4YPGT4GTNKKYO4P3K4UR7S2CNFSXSOBAE7SWI34MTRHVXTAMCDNZIHIOGMX3QUZT64D4HAT7STN2GCVZARI6M2JIMVY24AR35IVXKUTQ2JU4AAX7735YCPE5BBMDDWBQJYS3NM22UBKMR5IQTS42KNKKAIN5F5OFIDBV5CFDP3JHF',
+ ]
+
+ d = DecodeQR()
+ for i in parts:
+ if d.add_data(i) == DecodeQRStatus.COMPLETE:
+ break
+ assert d.qr_type == QRType.PSBT__BBQR
+
+ #complete should be true
+ assert d.is_complete
+
+ tx = d.get_psbt()
+
+ assert str(tx) == base64_psbt
+
+ assert tx.inputs[0].witness_utxo.value == 3826 # input 1 amount in psbt
+ assert tx.inputs[1].witness_utxo.value == 1366 # input 2 amount in psbt
+ assert tx.inputs[2].witness_utxo.value == 4398 # input 3 amount in psbt
+ assert tx.inputs[3].witness_utxo.value == 3716 # input 4 amount in psbt
+ assert tx.inputs[4].witness_utxo.value == 2811 # input 5 amount in psbt
+
+ assert len(tx.outputs) == 1
+
+
diff --git a/tests/test_embit_utils.py b/tests/test_embit_utils.py
index 52f37b75..7eaf1835 100644
--- a/tests/test_embit_utils.py
+++ b/tests/test_embit_utils.py
@@ -283,7 +283,7 @@ def test_get_multisig_address():
from embit.descriptor import Descriptor
- # jdlcdl: these vectors created with electrum & sparrow as a 2 of 3 multisig based on bip39-bip32-standard-path wallets
+ # jdlcdl: these vectors created with electrum & sparrow as a 2 of 3 multisig based on BIP-39 and BIP-32 standard-path wallets
# keystore1 = 0x00*16 = 73c5da0a = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
# keystore2 = 0x11*16 = 0be174ee = 'baby mass dust captain baby mass dust captain baby mass dust casino'
# keystore3 = 0x22*16 = 8d55ff0d = 'captain baby mass dust captain baby mass dust captain baby mass dutch'
@@ -345,6 +345,35 @@ def test_get_multisig_address():
func(descriptor=descriptor, index=args[1], is_change=args[2], embit_network=args[3])
+def test_get_multisig_policy():
+ """
+ tests seedsigner.helpers.embit_utils.get_multisig_policy()
+ """
+ from embit.descriptor import Descriptor
+
+ # Reuses the same 2-of-3 multisig descriptors from test_get_multisig_address
+ vectors_descriptor_expected = {
+ # native segwit 2-of-3
+ "wsh(sortedmulti(2,[8d55ff0d/48h/1h/0h/2h]tpubDDxNVWk924RTUhdkVB2uLHw1hGMPNMGufpZefhkkswjbZppVZcuMdjYKQN4ewUog9vbL6RBLFPRWcgTGT7kYP79N6thyJ43ELUs4N2szXMg/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*,[0be174ee/48h/1h/0h/2h]tpubDEsePyLPkbxbrDiZSTTWdsviiNtiQjrvvzZnkLtG72QYLBygEsXePRsTdXi8DeMA7taCuuvoEBjUAfFrsNZeQJqfvG9fFoujYWbFPYUn7ux/{0,1}/*))#zw6cnrlk": ("2", "3"),
+ # nested segwit 2-of-3
+ "sh(wsh(sortedmulti(2,[73c5da0a/48h/1h/1h/0h/1h]tpubDFH9dgzveyD8yHQb8VrpG8FYAuwcLMHMje2CCcbBo1FpaGzYVtJeYYxcYgRqSTta5utUFts8nPPHs9C2bqoxrey5jia6Dwf9mpwrPq7YvcJ/{0,1}/*,[0be174ee/48h/1h/0h/1h]tpubDEsePyLPkbxbnj6XuKvWwdERHaKkikZxaGJ9sJqmM7okbZXgkNSFiGU6GX6qEes6kD8f9Z9FosYB9UEnBSgBEyEwwJhj4uUcFE1WE8VtKoh/{0,1}/*,[8d55ff0d/48h/1h/0h/1h]tpubDDxNVWk924RTT3vyGLHdSDoZ2JUVX7jUsPcwCQ9MrKHAtJrW5zECTF9rFHCvqu526E4PjHp61hBknts2c5aGexvX7hvCZ8TGPvQFdzxxy59/{0,1}/*)))#2ujlfp73": ("2", "3"),
+ # legacy p2sh 2-of-3
+ "sh(sortedmulti(2,[8d55ff0d/45h]tpubDANogJ2yfnizHwX7fSi5kUVzybyuPXDhgHB2TR9TUvkSLZFW73cRq4STKFDpx7qjJJiisyq82tbu4CeiYtmKEmT1xoCq9P8BPvXV31HUh6d/{0,1}/*,[0be174ee/45h]tpubDBkeVF2tDNT1Pz7L47iJeBB6RokU12LX6x4E6Ph8T89hmjQfB77q1AMyGwL8qpREVGq9sCJEbWwmnemwNTxnpxGn1di7BGy8jx9wEi5Vahu/{0,1}/*,[73c5da0a/45h]tpubDBKsGC1UqBDNvx9aivFmxZNgeZTUnmsCFGhWrqkLzucUCDePvbWWm3n8tAaAwMmxBG2ihdKCG9fzBdUnMxKx5PrkiqSZFi6Vkv6msUs9ddN/{0,1}/*))#p5t8sa8c": ("2", "3"),
+ }
+
+ for desc_str, (expected_threshold, expected_n) in vectors_descriptor_expected.items():
+ descriptor = Descriptor.from_string(desc_str)
+ threshold, n = embit_utils.get_multisig_policy(descriptor)
+ assert threshold == expected_threshold
+ assert n == expected_n
+
+ # Non-multisig descriptor should raise ValueError
+ with pytest.raises(ValueError):
+ embit_utils.get_multisig_policy(Descriptor.from_string(
+ "wpkh([73c5da0a/84h/1h/0h]tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba/{0,1}/*)#2aj6cvca"
+ ))
+
+
def test_parse_derivation_path():
# Shouldn't care if input uses "'" or "h"
derivation_path = "m/84'/0'/0'/0/0"
diff --git a/tests/test_encodepsbtqr.py b/tests/test_encodepsbtqr.py
index 2d864c12..3b33c0c3 100644
--- a/tests/test_encodepsbtqr.py
+++ b/tests/test_encodepsbtqr.py
@@ -1,4 +1,4 @@
-from seedsigner.models.encode_qr import CompactSeedQrEncoder, SeedQrEncoder, SpecterXPubQrEncoder, StaticXpubQrEncoder, UrPsbtQrEncoder, UrXpubQrEncoder
+from seedsigner.models.encode_qr import CompactSeedQrEncoder, SeedQrEncoder, SpecterLegacyXPubQrEncoder, StaticXpubQrEncoder, UrPsbtQrEncoder, UrXpubQrEncoder
from embit import psbt
from binascii import a2b_base64
@@ -54,7 +54,7 @@ def test_xpub_qr():
def test_specter_xpub_qr():
mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash"
- e = SpecterXPubQrEncoder(seed=Seed(mnemonic.split(" "), passphrase="pass"), network=SettingsConstants.TESTNET, derivation="m/48h/1h/0h/2h", qr_density=SettingsConstants.DENSITY__LOW)
+ e = SpecterLegacyXPubQrEncoder(seed=Seed(mnemonic.split(" "), passphrase="pass"), network=SettingsConstants.TESTNET, derivation="m/48h/1h/0h/2h", qr_density=SettingsConstants.DENSITY__LOW)
assert e.next_part() == "p1of4 [c49122a5/48h/1h/0h/2h]Vpub5mXgECaX5yYDN"
assert e.next_part() == "p2of4 c5VnUG4jVNptyEg65qUjuofWchQeuMWWiq8rcPBo"
diff --git a/tests/test_flows.py b/tests/test_flows.py
index 249f25be..e520de48 100644
--- a/tests/test_flows.py
+++ b/tests/test_flows.py
@@ -9,8 +9,12 @@ from seedsigner.models.seed import Seed
from seedsigner.views import scan_views
from seedsigner.views.psbt_views import PSBTSelectSeedView
from seedsigner.views.seed_views import SeedBackupView, SeedMnemonicEntryView, SeedOptionsView, SeedsMenuView
-from seedsigner.views.view import Destination, MainMenuView, PowerOptionsView, UnhandledExceptionView, View
+from seedsigner.views.view import Destination, MainMenuView, PowerOptionsView, UnhandledExceptionView, RemoveMicroSDWarningView, MainMenuView, View
from seedsigner.views.tools_views import ToolsMenuView, ToolsCalcFinalWordNumWordsView
+from seedsigner.views.settings_views import SettingsEntryUpdateSelectionView
+from seedsigner.models.settings_definition import SettingsDefinition
+from seedsigner.models.settings import SettingsConstants
+from seedsigner.hardware.microsd import MicroSD
@@ -167,14 +171,14 @@ class TestFlowTest(FlowTest):
def run(self):
self.run_screen(
ButtonListScreen,
- button_data=[ButtonOption("this is fine"), "this is not"]
+ button_data=[ButtonOption("This is fine"), "This is not"]
)
class MyGoodButtonDataTestView(View):
def run(self):
self.run_screen(
ButtonListScreen,
- button_data=[ButtonOption("this is fine"), ButtonOption("this is also fine")]
+ button_data=[ButtonOption("This is fine"), ButtonOption("This is also fine")]
)
return Destination(MainMenuView)
@@ -192,3 +196,47 @@ class TestFlowTest(FlowTest):
FlowStep(MainMenuView), # Need a next Destination to force the first step to run
])
+ def test_remove_microsd_blocking(self):
+ """
+ Verifies three related behaviors:
+
+ 1) If the RemoveMicroSDWarningView launches the SettingsEntryUpdateSelectionView
+ and the user presses Back without changing the tracked setting, the flow
+ returns to RemoveMicroSDWarningView (the blocking condition remains).
+ 2) If the user changes the tracked setting while in the settings entry, the
+ flow unblocks and navigates to MainMenuView.
+ 3) If the MicroSD is physically removed and the user presses Continue on the
+ warning, the flow proceeds to MainMenuView.
+ """
+ controller = Controller.get_instance()
+
+ settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__MICROSD_TOAST_TIMER)
+ controller.settings.set_value(settings_entry.attr_name, SettingsConstants.MICROSD_TOAST_TIMER_FOREVER)
+
+ # There are only two ways of exiting RemoveMicroSDWarningView when SETTING__MICROSD_TOAST_TIMER -> MICROSD_TOAST_TIMER_FOREVER
+ self.run_sequence([
+ FlowStep(RemoveMicroSDWarningView, button_data_selection=RemoveMicroSDWarningView.SETTINGS),
+ FlowStep(SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON),
+ FlowStep(RemoveMicroSDWarningView, button_data_selection=RemoveMicroSDWarningView.SETTINGS),
+ # 1) Modifying the setting
+ FlowStep(SettingsEntryUpdateSelectionView, screen_return_value=0),
+ FlowStep(MainMenuView)
+ ])
+
+ self.reset_controller()
+ controller = Controller.get_instance()
+
+ settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__MICROSD_TOAST_TIMER)
+ controller.settings.set_value(settings_entry.attr_name, SettingsConstants.MICROSD_TOAST_TIMER_FOREVER)
+
+ # 2) Removing the MicroSD card and pressing CONTINUE
+ self.mock_microsd.is_inserted = False
+ assert MicroSD.get_instance().is_inserted is False
+
+ self.run_sequence([
+ FlowStep(RemoveMicroSDWarningView, button_data_selection=RemoveMicroSDWarningView.CONTINUE),
+ FlowStep(MainMenuView)
+ ])
+
+ # Restore the setting for the controller
+ controller.settings.set_value(settings_entry.attr_name, SettingsConstants.MICROSD_TOAST_TIMER_FIVE_SECONDS)
\ No newline at end of file
diff --git a/tests/test_flows_l10n.py b/tests/test_flows_l10n.py
index 8ebe8aee..1814f995 100644
--- a/tests/test_flows_l10n.py
+++ b/tests/test_flows_l10n.py
@@ -13,7 +13,6 @@ from seedsigner.views.view import MainMenuView
class TestL10nFlows(FlowTest):
def test_change_locale(self):
settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__LOCALE)
- spanish_display_name = [locale_tuple[1] for locale_tuple in SettingsConstants.ALL_LOCALES if locale_tuple[0] == SettingsConstants.LOCALE__SPANISH][0]
# Initially we get English
assert _(MainMenuView.SCAN.button_label) == "Scan"
@@ -21,7 +20,7 @@ class TestL10nFlows(FlowTest):
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS),
FlowStep(settings_views.SettingsMenuView, button_data_selection=ButtonOption(settings_entry.display_name)),
- FlowStep(settings_views.SettingsEntryUpdateSelectionView, button_data_selection=ButtonOption(spanish_display_name)),
+ FlowStep(settings_views.LocaleSelectionView, screen_return_value=1), # Any index > 0 (English)
])
# Now we don't get English
diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py
index b8661800..4141e575 100644
--- a/tests/test_flows_psbt.py
+++ b/tests/test_flows_psbt.py
@@ -6,167 +6,166 @@ from seedsigner.views import scan_views, seed_views, psbt_views
from seedsigner.models.settings import SettingsConstants
-# TODO: Cleanup: convert TAB spacing to SPACE
class TestPSBTFlows(FlowTest):
- def test_scan_psbt_first_then_correct_seedqr_flow(self):
- """
- Selecting "Scan" from the MainMenuView and scanning a PSBT should enter the PSBTSelectSeedView flow
- when Scan a Seed is selected from PSBTSelectSeedView it should enter the ScanView flow
- when a SeedQR is scanned it should enter the PSBTOverviewView flow
- since the PSBT has change no warning is displayed and it should enter the PSBTMathView flow
- since the PSBT is not a self transfer it should enter the PSBTAddressDetailsView flow
- """
- def load_psbt_into_decoder(view: scan_views.ScanView):
- """
- PSBT Tx and Wallet Details
- - Single Sig Wallet P2WPKH (Native Segwit) with no passphrase
- - Regtest c751dc07 m/84'/1'/0' tpubDDZBrnxMxbVzqt8EoEiABPxeKzFWma5pra5UEbg3Wst1hrwr6feuvcy7Sov7cpuYx94ypuy1PQ9NDNoQagFs37wGALzLb5Ei3FvyJWPPPKZ
- - 2 Inputs
- - 56,522,834 sats
- - 1,990,245,069 sats
- - 4 Outputs
- - 1 Output to another wallet (bcrt1q7cw0wzy8g6mq5qvkpvhnk5gsps5ncy3srp0n2j) of 123,456 sats
- - 3 Outputs change
- - 3 outputs to emulate a fake mix to increase privacy
- - Change addresses are index 1/7, 1/8, 1/9
- - 1/7 address bcrt1q53j0xwuskuf5gnvynadh0hlazyy8srydlucrhg with amount 123,456 sats
- - 1/8 address bcrt1q5gtw3zfp4cx67yk5q42q6j6rfza8aqcwpyyslv with amount 1,990,121,477 sats
- - 1/9 address bcrt1q9rrg7399m43cn0yg4tz0v0ate89jgf2d6kpz7v with amount 56,399,242 sats
- - Fee 272 sats
- """
- view.decoder.add_data("cHNidP8BANgCAAAAAsTXZs3fz/dmGb6M80+jjvJZdYya+cw5bT/dGuhZFdSlAAAAAAD9////qo6xg/UZAvUkcbse1F+C9zbP/FeZNjThx7SCIn6eMCgBAAAAAP3///8EQOIBAAAAAAAWABSkZPM7kLcTRE2En1t33/0RCHgMjQXYnnYAAAAAFgAUKMaPRKXdY4m8iKrE9j+rycskJU1A4gEAAAAAABYAFPYc9wiHRrYKAZYLLztREAwpPBIwipVcAwAAAAAWABSiFuiJIa4NrxLUBVQNS0NIun6DDtoRAABPAQQ1h88DBcQGZIAAAAA+0J+jlNL3dpWwlnBi8Dx+Ipg4e6uvB3HdjzFPX7r9CAOOlAIxgII+/xCcj+XoEenKH7wj5s5wlu7Q7CCZWFLGLhA5Su0UVAAAgAEAAIAAAACAAAEA7QIAAAAEE6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0BAAAAAP3///8TqeNf9+e+fuFuQohFzHM1gU5J9sJ015ad3sV7/VRxDQMAAAAA/f///xOp41/3575+4W5CiEXMczWBTkn2wnTXlp3exXv9VHENBAAAAAD9////E6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0GAAAAAP3///8CUnheAwAAAAAWABRCfygPJ+Fjsx4BknYvvm3A3qKn2xJ/XQcAAAAAF6kU1I4TAst5nAj15ey7vwe5cM3OFq+HlhEAAAEBH1J4XgMAAAAAFgAUQn8oDyfhY7MeAZJ2L75twN6ip9sBAwQBAAAAIgYCo7sfm78RQY3B5n0ac/QF8VtMAzFnci+h5D1MtpgRY7oYOUrtFFQAAIABAACAAAAAgAEAAAAGAAAAAAEAcQIAAAABxY7wh0nsfJQfzWrD/9rN9BYsM+iOmPaO6I0ANFgO/PcAAAAAAP3///8CptiUAAAAAAAWABRIm4HhQY/TzOjeWSPRrbuJo9MlW826oHYAAAAAFgAU0z+0L2QSLGtyQTn8FhbCpcI7jbliAQAAAQEfzbqgdgAAAAAWABTTP7QvZBIsa3JBOfwWFsKlwjuNuQEDBAEAAAAiBgITHmebEANk81CraV4xZIpqkNjjw0tIvezl1Ism1NRH3Rg5Su0UVAAAgAEAAIAAAACAAQAAAAAAAAAAIgICuTT7WnuiUTpObjWnZFHzIeEvW9PTB+1LLVFNQJVFeIIYOUrtFFQAAIABAACAAAAAgAEAAAAHAAAAACICAk8f3hpc5C35chgSg+Pe2zZ9IhHREd4aKW2+yAMRIFeqGDlK7RRUAACAAQAAgAAAAIABAAAACQAAAAAAIgIDjt1CjvrnMMnjbmTNKUAYoKEDRbmKjNjbq+6Ppqj3bqQYOUrtFFQAAIABAACAAAAAgAEAAAAIAAAAAA==")
+ def test_scan_psbt_first_then_correct_seedqr_flow(self):
+ """
+ Selecting "Scan" from the MainMenuView and scanning a PSBT should enter the PSBTSelectSeedView flow
+ when Scan a Seed is selected from PSBTSelectSeedView it should enter the ScanView flow
+ when a SeedQR is scanned it should enter the PSBTOverviewView flow
+ since the PSBT has change no warning is displayed and it should enter the PSBTMathView flow
+ since the PSBT is not a self transfer it should enter the PSBTAddressDetailsView flow
+ """
+ def load_psbt_into_decoder(view: scan_views.ScanView):
+ """
+ PSBT Tx and Wallet Details
+ - Single Sig Wallet P2WPKH (Native Segwit) with no passphrase
+ - Regtest c751dc07 m/84'/1'/0' tpubDDZBrnxMxbVzqt8EoEiABPxeKzFWma5pra5UEbg3Wst1hrwr6feuvcy7Sov7cpuYx94ypuy1PQ9NDNoQagFs37wGALzLb5Ei3FvyJWPPPKZ
+ - 2 Inputs
+ - 56,522,834 sats
+ - 1,990,245,069 sats
+ - 4 Outputs
+ - 1 Output to another wallet (bcrt1q7cw0wzy8g6mq5qvkpvhnk5gsps5ncy3srp0n2j) of 123,456 sats
+ - 3 Outputs change
+ - 3 outputs to emulate a fake mix to increase privacy
+ - Change addresses are index 1/7, 1/8, 1/9
+ - 1/7 address bcrt1q53j0xwuskuf5gnvynadh0hlazyy8srydlucrhg with amount 123,456 sats
+ - 1/8 address bcrt1q5gtw3zfp4cx67yk5q42q6j6rfza8aqcwpyyslv with amount 1,990,121,477 sats
+ - 1/9 address bcrt1q9rrg7399m43cn0yg4tz0v0ate89jgf2d6kpz7v with amount 56,399,242 sats
+ - Fee 272 sats
+ """
+ view.decoder.add_data("cHNidP8BANgCAAAAAsTXZs3fz/dmGb6M80+jjvJZdYya+cw5bT/dGuhZFdSlAAAAAAD9////qo6xg/UZAvUkcbse1F+C9zbP/FeZNjThx7SCIn6eMCgBAAAAAP3///8EQOIBAAAAAAAWABSkZPM7kLcTRE2En1t33/0RCHgMjQXYnnYAAAAAFgAUKMaPRKXdY4m8iKrE9j+rycskJU1A4gEAAAAAABYAFPYc9wiHRrYKAZYLLztREAwpPBIwipVcAwAAAAAWABSiFuiJIa4NrxLUBVQNS0NIun6DDtoRAABPAQQ1h88DBcQGZIAAAAA+0J+jlNL3dpWwlnBi8Dx+Ipg4e6uvB3HdjzFPX7r9CAOOlAIxgII+/xCcj+XoEenKH7wj5s5wlu7Q7CCZWFLGLhA5Su0UVAAAgAEAAIAAAACAAAEA7QIAAAAEE6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0BAAAAAP3///8TqeNf9+e+fuFuQohFzHM1gU5J9sJ015ad3sV7/VRxDQMAAAAA/f///xOp41/3575+4W5CiEXMczWBTkn2wnTXlp3exXv9VHENBAAAAAD9////E6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0GAAAAAP3///8CUnheAwAAAAAWABRCfygPJ+Fjsx4BknYvvm3A3qKn2xJ/XQcAAAAAF6kU1I4TAst5nAj15ey7vwe5cM3OFq+HlhEAAAEBH1J4XgMAAAAAFgAUQn8oDyfhY7MeAZJ2L75twN6ip9sBAwQBAAAAIgYCo7sfm78RQY3B5n0ac/QF8VtMAzFnci+h5D1MtpgRY7oYOUrtFFQAAIABAACAAAAAgAEAAAAGAAAAAAEAcQIAAAABxY7wh0nsfJQfzWrD/9rN9BYsM+iOmPaO6I0ANFgO/PcAAAAAAP3///8CptiUAAAAAAAWABRIm4HhQY/TzOjeWSPRrbuJo9MlW826oHYAAAAAFgAU0z+0L2QSLGtyQTn8FhbCpcI7jbliAQAAAQEfzbqgdgAAAAAWABTTP7QvZBIsa3JBOfwWFsKlwjuNuQEDBAEAAAAiBgITHmebEANk81CraV4xZIpqkNjjw0tIvezl1Ism1NRH3Rg5Su0UVAAAgAEAAIAAAACAAQAAAAAAAAAAIgICuTT7WnuiUTpObjWnZFHzIeEvW9PTB+1LLVFNQJVFeIIYOUrtFFQAAIABAACAAAAAgAEAAAAHAAAAACICAk8f3hpc5C35chgSg+Pe2zZ9IhHREd4aKW2+yAMRIFeqGDlK7RRUAACAAQAAgAAAAIABAAAACQAAAAAAIgIDjt1CjvrnMMnjbmTNKUAYoKEDRbmKjNjbq+6Ppqj3bqQYOUrtFFQAAIABAACAAAAAgAEAAAAIAAAAAA==")
- def load_seed_into_decoder(view: scan_views.ScanView):
- view.decoder.add_data("080115060387063104071857067618681125136207731354")
-
- self.run_sequence([
- FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
- FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored
- FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED),
- FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder),
- FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
- FlowStep(seed_views.SeedOptionsView, is_redirect=True),
- FlowStep(psbt_views.PSBTOverviewView),
- FlowStep(psbt_views.PSBTMathView),
- FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=0),
- FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT),
- FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT),
- FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT),
- FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT),
- FlowStep(psbt_views.PSBTSignedQRDisplayView),
- FlowStep(MainMenuView)
- ])
+ def load_seed_into_decoder(view: scan_views.ScanView):
+ view.decoder.add_data("080115060387063104071857067618681125136207731354")
+
+ self.run_sequence([
+ FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
+ FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored
+ FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED),
+ FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder),
+ FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
+ FlowStep(seed_views.SeedOptionsView, is_redirect=True),
+ FlowStep(psbt_views.PSBTOverviewView),
+ FlowStep(psbt_views.PSBTMathView),
+ FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=0),
+ FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT),
+ FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT),
+ FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT),
+ FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT),
+ FlowStep(psbt_views.PSBTSignedQRDisplayView),
+ FlowStep(MainMenuView)
+ ])
- def test_scan_psbt_first_then_load_electrum_seed(self):
- """
- Should be able to load an Electrum mnemonic after first loading in a psbt.
- """
- def load_psbt_into_decoder(view: scan_views.ScanView):
- # Single sig psbt for the below Electrum mnemonic
- view.decoder.add_data("cHNidP8BAHECAAAAAX9/d6VyI7nvVTyhLBfqu05za2AJ2Z0dKMC0cUX+S2U7AQAAAAD9////AgeHAAAAAAAAFgAUOnNPuZMD1sQudt3+7LvHBUvGhyd//gAAAAAAABYAFGO9QLvu4V9/hz6ZjbIGMrqsEiIYAjQTAAABAR+ghgEAAAAAABYAFKawrgcT62jmIVQwyHPCV0thmJWbAQDBAQAAAAABAYeHL9UQlz/jEKUuNNY3LTeQRjudjBinsP2L0ppvgRt0AAAAAAD/////AnbP3rsPAAAAIlEgtgmCioGjfKwp6f8rOoI4OPb+ZV8db581J9IizZPskl2ghgEAAAAAABYAFKawrgcT62jmIVQwyHPCV0thmJWbAUDCBlMh9VjZN2NdU9Wabi0o3Ct1q9YHTsJRLAkLfUuIHB+BE+ucR4bdGAJG5nBhCWOmCXbpRwKP1INRYvkuQ2fHAAAAACIGA2+PEYHyVy6nhYwAx5SJKBIWXjsWgjhhf/2FEWqXgxnoEKNOC3gAAACAAAAAAAAAAAAAACICA0SBeeHxfHdny6rUnQJuteAnQ7shSydexjJCkSJarn3mEKNOC3gAAACAAQAAAAEAAAAA")
+ def test_scan_psbt_first_then_load_electrum_seed(self):
+ """
+ Should be able to load an Electrum mnemonic after first loading in a psbt.
+ """
+ def load_psbt_into_decoder(view: scan_views.ScanView):
+ # Single sig psbt for the below Electrum mnemonic
+ view.decoder.add_data("cHNidP8BAHECAAAAAX9/d6VyI7nvVTyhLBfqu05za2AJ2Z0dKMC0cUX+S2U7AQAAAAD9////AgeHAAAAAAAAFgAUOnNPuZMD1sQudt3+7LvHBUvGhyd//gAAAAAAABYAFGO9QLvu4V9/hz6ZjbIGMrqsEiIYAjQTAAABAR+ghgEAAAAAABYAFKawrgcT62jmIVQwyHPCV0thmJWbAQDBAQAAAAABAYeHL9UQlz/jEKUuNNY3LTeQRjudjBinsP2L0ppvgRt0AAAAAAD/////AnbP3rsPAAAAIlEgtgmCioGjfKwp6f8rOoI4OPb+ZV8db581J9IizZPskl2ghgEAAAAAABYAFKawrgcT62jmIVQwyHPCV0thmJWbAUDCBlMh9VjZN2NdU9Wabi0o3Ct1q9YHTsJRLAkLfUuIHB+BE+ucR4bdGAJG5nBhCWOmCXbpRwKP1INRYvkuQ2fHAAAAACIGA2+PEYHyVy6nhYwAx5SJKBIWXjsWgjhhf/2FEWqXgxnoEKNOC3gAAACAAAAAAAAAAAAAACICA0SBeeHxfHdny6rUnQJuteAnQ7shSydexjJCkSJarn3mEKNOC3gAAACAAQAAAAEAAAAA")
- self.settings.set_value(SettingsConstants.SETTING__ELECTRUM_SEEDS, SettingsConstants.OPTION__ENABLED)
+ self.settings.set_value(SettingsConstants.SETTING__ELECTRUM_SEEDS, SettingsConstants.OPTION__ENABLED)
- sequence = [
- FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
- FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored
- FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.TYPE_ELECTRUM),
- FlowStep(seed_views.SeedElectrumMnemonicStartView),
- ]
+ sequence = [
+ FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
+ FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored
+ FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.TYPE_ELECTRUM),
+ FlowStep(seed_views.SeedElectrumMnemonicStartView),
+ ]
- # Load the associated Electrum mnemonic during the flow
- for word in "apple drip silly junior language resource unaware whale snake copy gravity tank".split():
- sequence += [
- FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word),
- ]
+ # Load the associated Electrum mnemonic during the flow
+ for word in "apple drip silly junior language resource unaware whale snake copy gravity tank".split():
+ sequence += [
+ FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word),
+ ]
- sequence += [
- FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
- FlowStep(seed_views.SeedOptionsView, is_redirect=True),
- FlowStep(psbt_views.PSBTOverviewView),
- FlowStep(psbt_views.PSBTMathView),
- ]
+ sequence += [
+ FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
+ FlowStep(seed_views.SeedOptionsView, is_redirect=True),
+ FlowStep(psbt_views.PSBTOverviewView),
+ FlowStep(psbt_views.PSBTMathView),
+ ]
- self.run_sequence(sequence)
+ self.run_sequence(sequence)
- def test_scan_multisig_psbt_seed_already_signed_flow(self):
-
- def load_psbt_into_decoder(view: scan_views.ScanView):
- view.decoder.add_data("cHNidP8BAIkCAAAAAc9dCSh2RcRPfHaT5bNVBpbg0jAekRLqOK+bpN/QA0jeAAAAAAD9////AtAHAAAAAAAAIlEg24shYsV3IRCzlgmMKjAsR4Ad9tX896z7zDAi5q0TU9H3CgAAAAAAACIAIByGQg/VP2aRID62ty40E64HYZeRRsKRGLt8J/76R6stQ04FAE8BBDWHzwSLLGdzgAAAAq3q6nR20JnHR+vKrBQdWxN9C7xU8zNX942mVF7AQpl2ArrdLwVlkGxaatQJ4wwkvypNBKbwOq9hXGLNlKi7rZWAFDUxzXUwAACAAQAAgAAAAIACAACATwEENYfPBHOCZmWAAAACmH6KTXIny0vueRgQFBq4M6oMuG8f1QM0I/RzKQ03bCgCHrF0fyUtV0+FD2N34u/woqb8MAt/o+7Ed58RddhY8zYUCUjSaDAAAIABAACAAAAAgAIAAIAAAQEriBMAAAAAAAAiACBY4WsjDgJXLj3VW222jU1tkIIhT26ce/2efH73BWGGBiICAqyfkrdUO662QBrdvJcSOZMFxniD7M1awm9U0Kb5XCm5RzBEAiAPkQTY84YjFFkpD6MI2cc5rJySqws5fsTQA/8XEZFpbAIgTNVykbEH4Z7bqyzhhy6lty0K8rtCUDCaHNv+47NNIWgBAQMEAQAAAAEFR1IhApL4XO+VE1pPYn5wnRFyJQKVSc9TX2dO6KIBH6jwvgPaIQKsn5K3VDuutkAa3byXEjmTBcZ4g+zNWsJvVNCm+VwpuVKuIgYCkvhc75UTWk9ifnCdEXIlApVJz1NfZ07oogEfqPC+A9ocNTHNdTAAAIABAACAAAAAgAIAAIAAAAAAAAAAACIGAqyfkrdUO662QBrdvJcSOZMFxniD7M1awm9U0Kb5XCm5HAlI0mgwAACAAQAAgAAAAIACAACAAAAAAAAAAAAAAAEBR1IhApYXaczuYbBM/A+EH639Ir2yIB4PxL46dK/I1V1O9aHgIQLa02HCI/+EP+9gGpxHskjYWFN5hZzXY7RRvwV4UF42ylKuIgIClhdpzO5hsEz8D4Qfrf0ivbIgHg/Evjp0r8jVXU71oeAcNTHNdTAAAIABAACAAAAAgAIAAIABAAAAAAAAACICAtrTYcIj/4Q/72AanEeySNhYU3mFnNdjtFG/BXhQXjbKHAlI0mgwAACAAQAAgAAAAIACAACAAQAAAAAAAAAA")
-
- def load_seed_into_decoder(view: scan_views.ScanView):
- view.decoder.add_data("073318950739065415961602009907670428187212261116")
-
- self.run_sequence([
- FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
- FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored
- FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED),
- FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder),
- FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
- FlowStep(seed_views.SeedOptionsView, is_redirect=True),
- FlowStep(psbt_views.PSBTOverviewView),
- FlowStep(psbt_views.PSBTMathView),
- FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=0),
- FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.SKIP_VERIFICATION),
- FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT),
- FlowStep(psbt_views.PSBTSigningErrorView, button_data_selection=psbt_views.PSBTSigningErrorView.SELECT_DIFF_SEED),
- FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED),
- FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder),
- FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE),
- FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="abc")),
- FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE),
- FlowStep(seed_views.SeedOptionsView, is_redirect=True),
- FlowStep(psbt_views.PSBTOverviewView),
- FlowStep(psbt_views.PSBTMathView),
- FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=0),
- FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.SKIP_VERIFICATION),
- FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT),
- FlowStep(psbt_views.PSBTSignedQRDisplayView),
- FlowStep(MainMenuView),
- ])
+ def test_scan_multisig_psbt_seed_already_signed_flow(self):
+
+ def load_psbt_into_decoder(view: scan_views.ScanView):
+ view.decoder.add_data("cHNidP8BAIkCAAAAAc9dCSh2RcRPfHaT5bNVBpbg0jAekRLqOK+bpN/QA0jeAAAAAAD9////AtAHAAAAAAAAIlEg24shYsV3IRCzlgmMKjAsR4Ad9tX896z7zDAi5q0TU9H3CgAAAAAAACIAIByGQg/VP2aRID62ty40E64HYZeRRsKRGLt8J/76R6stQ04FAE8BBDWHzwSLLGdzgAAAAq3q6nR20JnHR+vKrBQdWxN9C7xU8zNX942mVF7AQpl2ArrdLwVlkGxaatQJ4wwkvypNBKbwOq9hXGLNlKi7rZWAFDUxzXUwAACAAQAAgAAAAIACAACATwEENYfPBHOCZmWAAAACmH6KTXIny0vueRgQFBq4M6oMuG8f1QM0I/RzKQ03bCgCHrF0fyUtV0+FD2N34u/woqb8MAt/o+7Ed58RddhY8zYUCUjSaDAAAIABAACAAAAAgAIAAIAAAQEriBMAAAAAAAAiACBY4WsjDgJXLj3VW222jU1tkIIhT26ce/2efH73BWGGBiICAqyfkrdUO662QBrdvJcSOZMFxniD7M1awm9U0Kb5XCm5RzBEAiAPkQTY84YjFFkpD6MI2cc5rJySqws5fsTQA/8XEZFpbAIgTNVykbEH4Z7bqyzhhy6lty0K8rtCUDCaHNv+47NNIWgBAQMEAQAAAAEFR1IhApL4XO+VE1pPYn5wnRFyJQKVSc9TX2dO6KIBH6jwvgPaIQKsn5K3VDuutkAa3byXEjmTBcZ4g+zNWsJvVNCm+VwpuVKuIgYCkvhc75UTWk9ifnCdEXIlApVJz1NfZ07oogEfqPC+A9ocNTHNdTAAAIABAACAAAAAgAIAAIAAAAAAAAAAACIGAqyfkrdUO662QBrdvJcSOZMFxniD7M1awm9U0Kb5XCm5HAlI0mgwAACAAQAAgAAAAIACAACAAAAAAAAAAAAAAAEBR1IhApYXaczuYbBM/A+EH639Ir2yIB4PxL46dK/I1V1O9aHgIQLa02HCI/+EP+9gGpxHskjYWFN5hZzXY7RRvwV4UF42ylKuIgIClhdpzO5hsEz8D4Qfrf0ivbIgHg/Evjp0r8jVXU71oeAcNTHNdTAAAIABAACAAAAAgAIAAIABAAAAAAAAACICAtrTYcIj/4Q/72AanEeySNhYU3mFnNdjtFG/BXhQXjbKHAlI0mgwAACAAQAAgAAAAIACAACAAQAAAAAAAAAA")
+
+ def load_seed_into_decoder(view: scan_views.ScanView):
+ view.decoder.add_data("073318950739065415961602009907670428187212261116")
+
+ self.run_sequence([
+ FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
+ FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored
+ FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED),
+ FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder),
+ FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
+ FlowStep(seed_views.SeedOptionsView, is_redirect=True),
+ FlowStep(psbt_views.PSBTOverviewView),
+ FlowStep(psbt_views.PSBTMathView),
+ FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=0),
+ FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.SKIP_VERIFICATION),
+ FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT),
+ FlowStep(psbt_views.PSBTSigningErrorView, button_data_selection=psbt_views.PSBTSigningErrorView.SELECT_DIFF_SEED),
+ FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED),
+ FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder),
+ FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE),
+ FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="abc")),
+ FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE),
+ FlowStep(seed_views.SeedOptionsView, is_redirect=True),
+ FlowStep(psbt_views.PSBTOverviewView),
+ FlowStep(psbt_views.PSBTMathView),
+ FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=0),
+ FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.SKIP_VERIFICATION),
+ FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT),
+ FlowStep(psbt_views.PSBTSignedQRDisplayView),
+ FlowStep(MainMenuView),
+ ])
- def test_parse_and_display_op_return_content(self):
- """
- PSBTs that include an OP_RETURN should be able to be parsed like any other
- PSBT and route to the dedicated OP_RETURN View to display the content
- """
- def load_psbt_into_decoder(view: scan_views.ScanView):
- """
- PSBT Tx and Wallet Details
- - Single Sig Wallet P2WPKH (Native Segwit) with no passphrase
- - Regtest 0fb882ff m/84'/1'/0' tpubDCfk37PqcQx6nFtFVuYHvRLJHxvYj33NjHkKRyRmWyCjyJ64sYBXyVjsTHaLBp5GLhM91VBgJ8nKDWDu52J2xVRy64c7ybEjjyWQJuQGLcg
- - 1 Input
- - 99,992,460 sats
- - 2 Outputs
- - 1 Output back to self (bcrt1qvwkhakqhz7m7kmz6332avatsmdy32m644g86vv) of 99,992,296 sats
- - 1 OP_RETURN: "Chancellor on the brink of third bailout"
- - Fee 164 sats
- """
- view.decoder.add_data("cHNidP8BAIYCAAAAATpQ10o+gKdZ8ThpKsbfHiHYn3NhvUrQ5DvW0ZWX8jKLAAAAAAD9////AujC9QUAAAAAFgAUY61+2BcXt+tsWoxV1nVw20kVb1UAAAAAAAAAACtqTChDaGFuY2VsbG9yIG9uIHRoZSBicmluayBvZiB0aGlyZCBiYWlsb3V0aQAAAE8BBDWHzwNXmUmVgAAAANRFa7R5gYD84Wbha3d1QnjgfYPOBw87on6cXS32WoyqAsPFtPxB7PRTdbujUnBPUVDh9YUBtwrl4nc0OcRNGvIyEA+4gv9UAACAAQAAgAAAAIAAAQB0AgAAAAGNFK/1X0fP5q+nu5XX7Tk2VRa0EL+jkGI9CHiJvsjZCgAAAAAA/f///wKMw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAAAAAAAAAAAZakwWYml0Y29pbiBpcyBmcmVlIHNwZWVjaGgAAAABAR+Mw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAQMEAQAAACIGAvxDI0eNI1oQ2AU69R7A0jf+hUdilWCgrWHgdzkqlaXMGA+4gv9UAACAAQAAgAAAAIAAAAAAAQAAAAAiAgK9qKtzGWyiRrpmupdA99NVLriz3GQy6cENbyD19sfl/hgPuIL/VAAAgAEAAIAAAACAAAAAAAIAAAAAAA==")
+ def test_parse_and_display_op_return_content(self):
+ """
+ PSBTs that include an OP_RETURN should be able to be parsed like any other
+ PSBT and route to the dedicated OP_RETURN View to display the content
+ """
+ def load_psbt_into_decoder(view: scan_views.ScanView):
+ """
+ PSBT Tx and Wallet Details
+ - Single Sig Wallet P2WPKH (Native Segwit) with no passphrase
+ - Regtest 0fb882ff m/84'/1'/0' tpubDCfk37PqcQx6nFtFVuYHvRLJHxvYj33NjHkKRyRmWyCjyJ64sYBXyVjsTHaLBp5GLhM91VBgJ8nKDWDu52J2xVRy64c7ybEjjyWQJuQGLcg
+ - 1 Input
+ - 99,992,460 sats
+ - 2 Outputs
+ - 1 Output back to self (bcrt1qvwkhakqhz7m7kmz6332avatsmdy32m644g86vv) of 99,992,296 sats
+ - 1 OP_RETURN: "Chancellor on the brink of third bailout"
+ - Fee 164 sats
+ """
+ view.decoder.add_data("cHNidP8BAIYCAAAAATpQ10o+gKdZ8ThpKsbfHiHYn3NhvUrQ5DvW0ZWX8jKLAAAAAAD9////AujC9QUAAAAAFgAUY61+2BcXt+tsWoxV1nVw20kVb1UAAAAAAAAAACtqTChDaGFuY2VsbG9yIG9uIHRoZSBicmluayBvZiB0aGlyZCBiYWlsb3V0aQAAAE8BBDWHzwNXmUmVgAAAANRFa7R5gYD84Wbha3d1QnjgfYPOBw87on6cXS32WoyqAsPFtPxB7PRTdbujUnBPUVDh9YUBtwrl4nc0OcRNGvIyEA+4gv9UAACAAQAAgAAAAIAAAQB0AgAAAAGNFK/1X0fP5q+nu5XX7Tk2VRa0EL+jkGI9CHiJvsjZCgAAAAAA/f///wKMw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAAAAAAAAAAAZakwWYml0Y29pbiBpcyBmcmVlIHNwZWVjaGgAAAABAR+Mw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAQMEAQAAACIGAvxDI0eNI1oQ2AU69R7A0jf+hUdilWCgrWHgdzkqlaXMGA+4gv9UAACAAQAAgAAAAIAAAAAAAQAAAAAiAgK9qKtzGWyiRrpmupdA99NVLriz3GQy6cENbyD19sfl/hgPuIL/VAAAgAEAAIAAAACAAAAAAAIAAAAAAA==")
- def load_seed_into_decoder(view: scan_views.ScanView):
- view.decoder.add_data("114006021552133507590698063102151531110102551496")
+ def load_seed_into_decoder(view: scan_views.ScanView):
+ view.decoder.add_data("114006021552133507590698063102151531110102551496")
- self.run_sequence([
- FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
- FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored
- FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED),
- FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder),
- FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
- FlowStep(seed_views.SeedOptionsView, is_redirect=True),
- FlowStep(psbt_views.PSBTOverviewView),
- FlowStep(psbt_views.PSBTMathView),
- FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT),
+ self.run_sequence([
+ FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
+ FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored
+ FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED),
+ FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder),
+ FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
+ FlowStep(seed_views.SeedOptionsView, is_redirect=True),
+ FlowStep(psbt_views.PSBTOverviewView),
+ FlowStep(psbt_views.PSBTMathView),
+ FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT),
- # Should route to display OP_RETURN content
- FlowStep(psbt_views.PSBTOpReturnView, button_data_selection=0),
+ # Should route to display OP_RETURN content
+ FlowStep(psbt_views.PSBTOpReturnView, button_data_selection=0),
- # Should be able to sign the psbt
- FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT),
- FlowStep(psbt_views.PSBTSignedQRDisplayView),
- FlowStep(MainMenuView)
- ])
+ # Should be able to sign the psbt
+ FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT),
+ FlowStep(psbt_views.PSBTSignedQRDisplayView),
+ FlowStep(MainMenuView)
+ ])
diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py
index c1bcc0bb..578f0094 100644
--- a/tests/test_flows_seed.py
+++ b/tests/test_flows_seed.py
@@ -35,7 +35,7 @@ class TestSeedFlows(FlowTest):
def test_passphrase_entry_flow(self):
"""
- Opting to add a bip39 passphrase on the Finalize Seed screen should enter the
+ Opting to add a BIP-39 passphrase on the Finalize Seed screen should enter the
passphrase entry / review flow and end at the SeedOptionsView.
"""
self.run_sequence([
@@ -61,7 +61,6 @@ class TestSeedFlows(FlowTest):
the SeedOptionsView.
"""
def test_with_mnemonic(mnemonic):
- Settings.HOSTNAME = "not seedsigner-os"
sequence = [
FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS),
FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView
@@ -128,7 +127,6 @@ class TestSeedFlows(FlowTest):
Most BIP-39 mnemonics should generate an error if entered as Electrum seeds.
"""
def test_with_mnemonic(mnemonic: list[str], custom_extension: str = None, expects_electrum_seed_is_valid: bool = True):
- Settings.HOSTNAME = "not seedsigner-os"
settings = Settings.get_instance()
settings.set_value(SettingsConstants.SETTING__ELECTRUM_SEEDS, SettingsConstants.OPTION__ENABLED)
@@ -184,7 +182,7 @@ class TestSeedFlows(FlowTest):
"""
Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the MainMenuView
"""
- def flowtest_standard_xpub(sig_tuple, script_tuple, coord_tuple):
+ def flowtest_standard_xpub(sig_tuple, script_tuple, xpub_qr_tuple):
if sig_tuple[0] == SettingsConstants.SINGLE_SIG:
sig_selection = seed_views.SeedExportXpubSigTypeView.SINGLE_SIG
else:
@@ -195,7 +193,7 @@ class TestSeedFlows(FlowTest):
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB),
FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=sig_selection),
FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=ButtonOption(script_tuple[1], return_data=script_tuple[0])),
- FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=ButtonOption(coord_tuple[1], return_data=coord_tuple[0])),
+ FlowStep(seed_views.SeedExportXpubQRFormatView, button_data_selection=ButtonOption(xpub_qr_tuple[1], return_data=xpub_qr_tuple[0])),
FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0),
@@ -211,17 +209,17 @@ class TestSeedFlows(FlowTest):
# these are lists of (constant_value, display_name) tuples
sig_types: list[tuple[str, str]] = SettingsConstants.ALL_SIG_TYPES
script_types: list[tuple[str, str]] = SettingsConstants.ALL_SCRIPT_TYPES
- coordinators: list[tuple[str, str]] = SettingsConstants.ALL_COORDINATORS
+ xpub_qr_formats: list[tuple[str, str]] = SettingsConstants.ALL_XPUB_QR_FORMATS
# enable non-defaults so they're available in views
self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in sig_types])
self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in script_types])
- self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in coordinators])
+ self.settings.set_value(SettingsConstants.SETTING__XPUB_QR_FORMAT, [x for x,y in xpub_qr_formats])
- # exhaustively test flows thru standard sig_types, script_types, and coordinators
+ # exhaustively test flows thru standard sig_types, script_types, and xpub_qr_formats
for sig_tuple in sig_types:
for script_tuple in script_types:
- for coord_tuple in coordinators:
+ for xpub_qr_tuple in xpub_qr_formats:
# skip custom derivation
if script_tuple[0] == SettingsConstants.CUSTOM_DERIVATION:
continue
@@ -229,13 +227,13 @@ class TestSeedFlows(FlowTest):
elif sig_tuple[0] == SettingsConstants.MULTISIG and script_tuple[0] == SettingsConstants.TAPROOT:
continue
else:
- print('\n\ntest_standard_xpubs(%s, %s, %s)' % (sig_tuple, script_tuple, coord_tuple))
- flowtest_standard_xpub(sig_tuple, script_tuple, coord_tuple)
+ print('\n\ntest_standard_xpubs(%s, %s, %s)' % (sig_tuple, script_tuple, xpub_qr_tuple))
+ flowtest_standard_xpub(sig_tuple, script_tuple, xpub_qr_tuple)
def test_export_xpub_disabled_not_available_flow(self):
"""
- If sig_type/script_type/coordinator disabled, then these options are not available
+ If sig_type/script_type/xpub_qr_format disabled, then these options are not available
"""
# Load a finalized Seed into the Controller
mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split()
@@ -245,17 +243,17 @@ class TestSeedFlows(FlowTest):
# these are lists of (constant_value, display_name) tuples
sig_types: list[tuple[str, str]] = SettingsConstants.ALL_SIG_TYPES
script_types: list[tuple[str, str]] = SettingsConstants.ALL_SCRIPT_TYPES
- coordinators: list[tuple[str, str]] = SettingsConstants.ALL_COORDINATORS
+ xpub_qr_formats: list[tuple[str, str]] = SettingsConstants.ALL_XPUB_QR_FORMATS
# these are the disabled types that we will be testing
disabled_sig = SettingsConstants.MULTISIG
disabled_script = SettingsConstants.TAPROOT
- disabled_coord = SettingsConstants.COORDINATOR__NUNCHUK
+ disabled_xpub_qr_format = SettingsConstants.XPUB_QR_FORMAT__SPECTER_LEGACY
# enable all but our target disabled type
self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in sig_types if x!=disabled_sig])
self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in script_types if x!=disabled_script])
- self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in coordinators if x!=disabled_coord])
+ self.settings.set_value(SettingsConstants.SETTING__XPUB_QR_FORMAT, [x for x,y in xpub_qr_formats if x!=disabled_xpub_qr_format])
# If multisig isn't an option, then the sig type selection is skipped altogether
self.run_sequence(
@@ -286,7 +284,7 @@ class TestSeedFlows(FlowTest):
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB),
FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True),
FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=0),
- FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=disabled_coord),
+ FlowStep(seed_views.SeedExportXpubQRFormatView, button_data_selection=disabled_xpub_qr_format),
]
)
@@ -307,8 +305,8 @@ class TestSeedFlows(FlowTest):
SettingsConstants.CUSTOM_DERIVATION
])
- # Ensure that all coordinators are enabled
- self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x, y in SettingsConstants.ALL_COORDINATORS])
+ # Ensure that all xpub_qr_formats are enabled
+ self.settings.set_value(SettingsConstants.SETTING__XPUB_QR_FORMAT, [x for x, y in SettingsConstants.ALL_XPUB_QR_FORMATS])
# Set up button_data selections
sig_type = seed_views.SeedExportXpubSigTypeView.SINGLE_SIG
@@ -316,9 +314,9 @@ class TestSeedFlows(FlowTest):
custom_derivation = SettingsConstants.CUSTOM_DERIVATION
script_type = ButtonOption(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES)[2], return_data=custom_derivation)
- specter = SettingsConstants.COORDINATOR__SPECTER_DESKTOP
- assert SettingsConstants.ALL_COORDINATORS[3][0] == specter
- coordinator = ButtonOption(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[3], return_data=specter)
+ specter_legacy = SettingsConstants.XPUB_QR_FORMAT__SPECTER_LEGACY
+ assert SettingsConstants.ALL_XPUB_QR_FORMATS[2][0] == specter_legacy
+ xpub_qr_format = ButtonOption(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__XPUB_QR_FORMAT)[2], return_data=specter_legacy)
self.run_sequence(
initial_destination_view_args=dict(seed_num=0),
@@ -327,7 +325,7 @@ class TestSeedFlows(FlowTest):
FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=sig_type),
FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=script_type),
FlowStep(seed_views.SeedExportXpubCustomDerivationView, screen_return_value="m/0'/0'"),
- FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=coordinator),
+ FlowStep(seed_views.SeedExportXpubQRFormatView, button_data_selection=xpub_qr_format),
FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0),
@@ -338,18 +336,18 @@ class TestSeedFlows(FlowTest):
def test_export_xpub_skip_non_option_flow(self):
"""
- Export XPUB flows w/o user choices when no other options for sig_types, script_types, and/or coordinators
+ Export XPUB flows w/o user choices when no other options for sig_types, script_types, and/or xpub_qr_formats
"""
# Load a finalized Seed into the Controller
mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split()
self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic))
self.controller.storage.finalize_pending_seed()
- # exclusively set only one choice for each of sig_types, script_types and coordinators
+ # exclusively set only one choice for each of sig_types, script_types and xpub_qr_formats
self.settings.update({
SettingsConstants.SETTING__SIG_TYPES: SettingsConstants.MULTISIG,
SettingsConstants.SETTING__SCRIPT_TYPES: SettingsConstants.NESTED_SEGWIT,
- SettingsConstants.SETTING__COORDINATORS: SettingsConstants.COORDINATOR__SPECTER_DESKTOP,
+ SettingsConstants.SETTING__XPUB_QR_FORMAT: SettingsConstants.XPUB_QR_FORMAT__UR_CRYPTO_ACCOUNT,
})
self.run_sequence(
@@ -358,7 +356,7 @@ class TestSeedFlows(FlowTest):
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB),
FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True),
FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True),
- FlowStep(seed_views.SeedExportXpubCoordinatorView, is_redirect=True),
+ FlowStep(seed_views.SeedExportXpubQRFormatView, is_redirect=True),
FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0),
@@ -379,7 +377,7 @@ class TestSeedFlows(FlowTest):
# Make sure all options are enabled
self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in SettingsConstants.ALL_SIG_TYPES])
self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in SettingsConstants.ALL_SCRIPT_TYPES])
- self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in SettingsConstants.ALL_COORDINATORS])
+ self.settings.set_value(SettingsConstants.SETTING__XPUB_QR_FORMAT, [x for x,y in SettingsConstants.ALL_XPUB_QR_FORMATS])
self.run_sequence(
initial_destination_view_args=dict(seed_num=0),
@@ -389,7 +387,7 @@ class TestSeedFlows(FlowTest):
# Skips past the script type options via redirect
FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True),
- FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=ButtonOption(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[0], return_data=SettingsConstants.ALL_COORDINATORS[0][0])),
+ FlowStep(seed_views.SeedExportXpubQRFormatView, button_data_selection=ButtonOption(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__XPUB_QR_FORMAT)[0], return_data=SettingsConstants.ALL_XPUB_QR_FORMATS[0][0])),
FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0),
@@ -435,6 +433,9 @@ class TestSeedFlows(FlowTest):
def load_completely_wrong_qr_type_into_decoder(view: View):
view.decoder.add_data("I like cheese")
+ def load_recognized_qr_type_isnot_seed_into_decoder(view: View):
+ view.decoder.add_data("UR:CRYPTO-PSBT/HKADHEJOJKIDJYZMADAEGMAOAEAEAEADHHGLEYKGBDSBKEMTSARLVYSGIABEDELFSKWLLUDKLYESJOFDLPJPFXSOHHWZTYWEAEAEAEAEAEZCZMZMZMADBKBZJEDEHEAEAEAECMAEBBVEPFDWDMBBGSPFZTKIMYCLKPSOBDDTRDWTWPYKGAQDKNAEAEGWADAAECLTTKAXWSWTUTSNLAAEAEAEPKCTBSHGISSSRETIVEWSGYNEPTHTESNSWMLBTARYEMHTBTBTLNWSBKJLMKYNFGHLAXJYBBTTIDHKDICHAMLEHHDSRKATGDLYSBIYHNHDNEWPJKZSZMDKVETYNSGOCXKNDNBEOLFSRSNSGHAEAELAADAEAELAAEAEAELAAEADADCTKSBZJEDEHEAEAEAECMAEBBVEPFDWDMBBGSPFZTKIMYCLKPSOBDDTRDWTWPYKGAADAXAAADAEAEAECPAMAODSFTLDTYFRECSKVWYLKNBANNKKZCRTHYJTPSHLHNHKNBPDCLCFDMSOLPDKFXLFQZCSOLFSRSNSGHAEAELAADAEAELAAEAEAELAAEAEAEAEAEAEAEAEAECPAOAODSFTLDTYFRECSKVWYLKNBANNKKZCRTHYJTPSHLHNHKNBPDCLCFDMSOLPDKFXLFQZCSOLFSRSNSGHAEAELAADAEAELAAEAEAELAAEAEAEAEAEAEAEAEAELGMKFZCW")
+
def load_right_seed_into_decoder(view: View):
view.decoder.add_data("0000" * 11 + "0003")
@@ -446,20 +447,26 @@ class TestSeedFlows(FlowTest):
FlowStep(seed_views.SeedTranscribeSeedQRFormatView, button_data_selection=seed_views.SeedTranscribeSeedQRFormatView.STANDARD_12),
FlowStep(seed_views.SeedTranscribeSeedQRWarningView),
FlowStep(seed_views.SeedTranscribeSeedQRWholeQRView),
- FlowStep(seed_views.SeedTranscribeSeedQRZoomedInView, is_redirect=True), # Live interactive screens are a bit weird; not sure why `is_redirect` is necessary here
+ FlowStep(seed_views.SeedTranscribeSeedQRZoomedInView),
FlowStep(seed_views.SeedTranscribeSeedQRConfirmQRPromptView, button_data_selection=seed_views.SeedTranscribeSeedQRConfirmQRPromptView.SCAN),
# Intentionally "scan" the wrong SeedQR
FlowStep(seed_views.SeedTranscribeSeedQRConfirmScanView, before_run=load_wrong_seed_into_decoder),
FlowStep(seed_views.SeedTranscribeSeedQRConfirmWrongSeedView),
- FlowStep(seed_views.SeedTranscribeSeedQRZoomedInView, is_redirect=True), # Live interactive screens are still weird
+ FlowStep(seed_views.SeedTranscribeSeedQRZoomedInView),
# Intentionally scan QR data that makes no sense for this flow
FlowStep(seed_views.SeedTranscribeSeedQRConfirmQRPromptView, button_data_selection=seed_views.SeedTranscribeSeedQRConfirmQRPromptView.SCAN),
FlowStep(seed_views.SeedTranscribeSeedQRConfirmScanView, before_run=load_completely_wrong_qr_type_into_decoder),
FlowStep(seed_views.SeedTranscribeSeedQRConfirmInvalidQRView),
- FlowStep(seed_views.SeedTranscribeSeedQRZoomedInView, is_redirect=True), # Live interactive screens are still weird
+ FlowStep(seed_views.SeedTranscribeSeedQRZoomedInView),
+ # Intentionally scan QR data that makes no sense for this flow because is another QR recognized but is not a SeedQR (e.g., bitcoin address, psbt)
+ FlowStep(seed_views.SeedTranscribeSeedQRConfirmQRPromptView, button_data_selection=seed_views.SeedTranscribeSeedQRConfirmQRPromptView.SCAN),
+ FlowStep(seed_views.SeedTranscribeSeedQRConfirmScanView, before_run=load_recognized_qr_type_isnot_seed_into_decoder),
+ FlowStep(seed_views.SeedTranscribeSeedQRConfirmInvalidQRView),
+ FlowStep(seed_views.SeedTranscribeSeedQRZoomedInView),
+
# Now scan the correct SeedQR
FlowStep(seed_views.SeedTranscribeSeedQRConfirmQRPromptView, button_data_selection=seed_views.SeedTranscribeSeedQRConfirmQRPromptView.SCAN),
FlowStep(seed_views.SeedTranscribeSeedQRConfirmScanView, before_run=load_right_seed_into_decoder),
@@ -467,6 +474,98 @@ class TestSeedFlows(FlowTest):
FlowStep(seed_views.SeedOptionsView),
])
+ def test_transcribe_seedqr_screensaver_startable_status(self):
+ """
+ The controller should return False for screensaver startable status when SeedTranscribeSeedQRZoomedInView
+ is active.
+ """
+ # Load a finalized Seed into the Controller
+ mnemonic = ["abandon"] * 11 + ["about"]
+ self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic))
+ self.controller.storage.finalize_pending_seed()
+
+ self.run_sequence(
+ initial_destination_view_args={'num_modules': 21, 'seed_num': 0, 'seedqr_format': 'seed__seedqr'},
+ sequence=[
+ FlowStep(seed_views.SeedTranscribeSeedQRWholeQRView),
+ FlowStep(seed_views.SeedTranscribeSeedQRZoomedInView, is_redirect=True), # Live interactive screens are a bit weird; not sure why `is_redirect` is necessary here
+ ])
+
+ assert self.controller.is_screensaver_start_allowed == False
+
+
+
+class TestSeedEntryBackFlows(FlowTest):
+ """
+ Tests for every BACK exit scenario from SeedMnemonicEntryView and related views.
+
+ A naive BackStackView swap can leave resume_main_flow dangling, causing
+ auto-redirects on stale flow state. These tests verify that BACK navigation
+ returns to the correct parent view AND that no flow state leaks.
+ """
+
+ def test_back_from_seed_entry_first_word(self):
+ """
+ Pressing BACK on the first word of mnemonic entry should return to
+ the View that initiated the mnemonic entry process.
+ """
+ for seed_type in [seed_views.LoadSeedView.TYPE_12WORD, seed_views.LoadSeedView.TYPE_24WORD]:
+ self.run_sequence([
+ FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS),
+ FlowStep(seed_views.SeedsMenuView, is_redirect=True), # No seeds loaded; auto-redirects to LoadSeedView
+ FlowStep(seed_views.LoadSeedView, button_data_selection=seed_type),
+ FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=RET_CODE__BACK_BUTTON),
+ FlowStep(seed_views.LoadSeedView), # Should land here, NOT MainMenuView
+ ])
+ BaseTest.reset_controller()
+
+
+ def test_back_from_seed_entry_mid_word(self):
+ """
+ Pressing BACK from a middle word (eg. word #2) should return to the
+ previous SeedMnemonicEntryView (eg. word #1) via the back stack.
+ """
+ self.run_sequence([
+ FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS),
+ FlowStep(seed_views.SeedsMenuView, is_redirect=True),
+ FlowStep(seed_views.LoadSeedView, button_data_selection=seed_views.LoadSeedView.TYPE_12WORD),
+ FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value="abandon"), # word #1
+ FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK from word #2
+ FlowStep(seed_views.SeedMnemonicEntryView), # Returns to word #1
+ ])
+
+ # Verify we're back on word #1: word at index 0 should still be set
+ # from the previous entry, while word at index 1 should be unset.
+ assert self.controller.storage.get_pending_mnemonic_word(0) == "abandon"
+ assert self.controller.storage.get_pending_mnemonic_word(1) is None
+
+
+ def test_back_from_seed_entry_via_seed_select(self):
+ """
+ Backing out of mnemonic entry during an active flow must preserve
+ `resume_main_flow` so the user remains within that flow.
+ """
+ from seedsigner.controller import Controller
+ from seedsigner.models.settings import SettingsConstants
+
+ self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED)
+
+ def load_signmessage_into_decoder(view):
+ view.decoder.add_data("signmessage m/84h/0h/0h/0/0 ascii:test message")
+
+ self.run_sequence([
+ FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
+ FlowStep(scan_views.ScanView, before_run=load_signmessage_into_decoder),
+ FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True),
+ FlowStep(seed_views.SeedSelectSeedView, button_data_selection=seed_views.SeedSelectSeedView.TYPE_12WORD),
+ FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK on first word
+ FlowStep(seed_views.SeedSelectSeedView), # Should return here, in the sign message flow
+ ])
+
+ # Verify resume_main_flow is still set — user is still in the sign message flow
+ assert self.controller.resume_main_flow == Controller.FLOW__SIGN_MESSAGE
+
+
class TestMessageSigningFlows(FlowTest):
@@ -519,7 +618,6 @@ class TestMessageSigningFlows(FlowTest):
text=self.controller.sign_message_data["message"],
width=240 - 2*GUIConstants.EDGE_PADDING,
height=240 - GUIConstants.TOP_NAV_HEIGHT - 3*GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT,
- allow_text_overflow=True,
)
self.controller.sign_message_data["paged_message"] = paged
diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py
index 37adf006..ff73a3d7 100644
--- a/tests/test_flows_settings.py
+++ b/tests/test_flows_settings.py
@@ -37,16 +37,35 @@ class TestSettingsFlows(FlowTest):
def test_multiselect(self):
- """ Multiselect Settings options should stay in-place; requires BACK to exit. """
+ """
+ Multiselect Settings options should stay in-place; requires BACK to exit. If no
+ selections are made, route to the warning screen and return the user to the
+ settings entry until at least one option is selected.
+ """
# Which option are we testing?
- settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS)
+ settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SIG_TYPES)
+
+ # Enable all options to start
+ self.settings.set_value(settings_entry.attr_name, [option[0] for option in settings_entry.selection_options])
+
+ # Sanity check, we only expect two options for this setting
+ assert len(settings_entry.selection_options) == 2
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS),
+ FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.ADVANCED),
FlowStep(settings_views.SettingsMenuView, button_data_selection=ButtonOption(settings_entry.display_name)),
- FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=0), # select/deselect first option
- FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option
- FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option
+ FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=0), # deselect first option
+ FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # deselect second option
+ FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select second option
+ FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # deselect second option
+ FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK to exit
+
+ # Both options were deselected, should route to the warning screen
+ FlowStep(settings_views.SettingsSelectionRequiredWarningView),
+ FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=0), # select first option
+
+ # Now we can exit
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK to exit
FlowStep(settings_views.SettingsMenuView),
])
@@ -78,11 +97,11 @@ class TestSettingsFlows(FlowTest):
MainMenuView.
"""
def load_persistent_settingsqr_into_decoder(view: scan_views.ScanView):
- settingsqr_data_persistent: str = "settings::v1 name=Total_noob_mode persistent=E coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"
+ settingsqr_data_persistent: str = "settings::v1 name=Total_noob_mode persistent=E xpub_qr=urca,sta denom=thr network=M qr_density=M sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"
view.decoder.add_data(settingsqr_data_persistent)
def load_not_persistent_settingsqr_into_decoder(view: scan_views.ScanView):
- settingsqr_data_not_persistent: str = "settings::v1 name=Ephemeral_noob_mode persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"
+ settingsqr_data_not_persistent: str = "settings::v1 name=Ephemeral_noob_mode persistent=D xpub_qr=urca,sta denom=thr network=M qr_density=M sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"
view.decoder.add_data(settingsqr_data_not_persistent)
def _run_test(initial_setting_state: str, load_settingsqr_into_decoder: Callable, expected_setting_state: str):
diff --git a/tests/test_flows_view.py b/tests/test_flows_view.py
index 05a74429..dc688f9a 100644
--- a/tests/test_flows_view.py
+++ b/tests/test_flows_view.py
@@ -4,9 +4,11 @@ from unittest.mock import patch
from base import FlowTest, FlowStep
from seedsigner.gui.screens.screen import RET_CODE__POWER_BUTTON
+from seedsigner.hardware.camera import CameraConnectionError
from seedsigner.models.settings import Settings
+from seedsigner.views.scan_views import ScanView
from seedsigner.views.tools_views import ToolsCalcFinalWordNumWordsView, ToolsMenuView
-from seedsigner.views.view import MainMenuView, NotYetImplementedView, PowerOptionsView, PowerOffView, RestartView, UnhandledExceptionView, View
+from seedsigner.views.view import CameraConnectionErrorView, MainMenuView, NotYetImplementedView, PowerOptionsView, PowerOffView, RestartView, UnhandledExceptionView, View
@@ -28,7 +30,6 @@ class TestViewFlows(FlowTest):
"""
Basic flow from MainMenuView to PowerOffView
"""
- Settings.HOSTNAME = Settings.SEEDSIGNER_OS
self.run_sequence([
FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON),
FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF),
@@ -64,3 +65,21 @@ class TestViewFlows(FlowTest):
FlowStep(UnhandledExceptionView),
FlowStep(MainMenuView),
])
+
+
+ def test__camera_connection_error__flow(self):
+ """
+ Simulate a camera connection error and ensure that we get the
+ CameraConnectionErrorView.
+ """
+ # Force a camera exception during `ScanView.run()`
+ with patch('seedsigner.views.scan_views.ScanView.run') as mock_run:
+ mock_run.side_effect = CameraConnectionError()
+
+ self.run_sequence([
+ FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
+ FlowStep(ScanView),
+ FlowStep(UnhandledExceptionView, is_redirect=True),
+ FlowStep(CameraConnectionErrorView),
+ FlowStep(MainMenuView),
+ ])
\ No newline at end of file
diff --git a/tests/test_psbt_parser.py b/tests/test_psbt_parser.py
index 474d671b..a3c1ab91 100644
--- a/tests/test_psbt_parser.py
+++ b/tests/test_psbt_parser.py
@@ -1,13 +1,290 @@
+import pytest
+import random
+
from binascii import a2b_base64
-from embit import psbt
+from embit import bip32
+from embit.psbt import PSBT
from embit.descriptor import Descriptor
from seedsigner.models.psbt_parser import PSBTParser
from seedsigner.models.seed import Seed
from seedsigner.models.settings_definition import SettingsConstants
+from psbt_testing_util import PSBTTestData, create_output
+
+class TestPSBTParser:
+ """
+ Exhaustively test all supported script input and output types.
+ """
+ seed = PSBTTestData.seed
+
+ def run_basic_test(self, psbt_base64: str, change_data: str, self_transfer_data: str):
+ """
+ Constructs a series of test psbts that use the specified `psbt_base64` for the input(s).
+
+ * A spend to each recipient type + specified `change_data`
+ * Self-transfer back to sender via the `self_transfer_data`
+ * A full spend (no change) to each recipient type
+ * 1 mega psbt with an output to each recipient type + specified `change_data`
+ """
+ psbt: PSBT = PSBT.parse(a2b_base64(psbt_base64))
+ input_amount = sum([inp.utxo.value for inp in psbt.inputs])
+ recipient_amount = random.randint(200_000, 90_000_000)
+ fee_amount = 5_000
+ change_output = create_output(change_data, input_amount - recipient_amount - fee_amount)
+
+ # Spend the input(s) to each supported recipient type + change
+ for output in PSBTTestData.ALL_EXTERNAL_OUTPUTS:
+ psbt.outputs.clear()
+ psbt.outputs.append(create_output(output, recipient_amount))
+ psbt.outputs.append(change_output)
+
+ assert len(psbt.outputs) == 2
+ psbt_parser = PSBTParser(p=psbt, seed=self.seed, network=SettingsConstants.REGTEST)
+ assert psbt_parser.num_inputs == len(psbt.inputs)
+ assert psbt_parser.input_amount == input_amount
+ assert psbt_parser.num_destinations == 1
+ assert psbt_parser.num_change_outputs == 1
+ assert psbt_parser.spend_amount == recipient_amount
+ assert psbt_parser.change_amount == input_amount - recipient_amount - fee_amount
+ assert psbt_parser.fee_amount == fee_amount
+ assert psbt_parser.input_amount == psbt_parser.spend_amount + psbt_parser.change_amount + psbt_parser.fee_amount
+
+ # Internally cycle the input(s) back to sender via the `self_transfer_data`
+ psbt.outputs.clear()
+ psbt.outputs.append(create_output(self_transfer_data, input_amount - fee_amount))
+
+ assert len(psbt.outputs) == 1
+ psbt_parser = PSBTParser(p=psbt, seed=self.seed, network=SettingsConstants.REGTEST)
+ assert psbt_parser.num_inputs == len(psbt.inputs)
+ assert psbt_parser.input_amount == input_amount
+ assert psbt_parser.num_destinations == 0 # No external recipients == no destinations
+ assert psbt_parser.num_change_outputs == 1 # PSBTParser considers self-transfers == change
+ assert psbt_parser.spend_amount == 0 # No external recipients == nothing spent (ignores fee)
+ assert psbt_parser.change_amount == input_amount - fee_amount # PSBTParser considers self-transfers == change
+ assert psbt_parser.fee_amount == fee_amount
+ assert psbt_parser.input_amount == psbt_parser.spend_amount + psbt_parser.change_amount + psbt_parser.fee_amount
+
+ # Now do full spends with no change
+ fee_amount = random.randint(5_000, 100_000)
+ recipient_amount = input_amount - fee_amount
+
+ for output in PSBTTestData.ALL_EXTERNAL_OUTPUTS:
+ psbt.outputs.clear()
+ psbt.outputs.append(create_output(output, recipient_amount))
+
+ assert len(psbt.outputs) == 1
+ psbt_parser = PSBTParser(p=psbt, seed=self.seed, network=SettingsConstants.REGTEST)
+ assert psbt_parser.num_inputs == len(psbt.inputs)
+ assert psbt_parser.input_amount == input_amount
+ assert psbt_parser.num_destinations == 1
+ assert psbt_parser.num_change_outputs == 0
+ assert psbt_parser.spend_amount == recipient_amount
+ assert psbt_parser.change_amount == 0
+ assert psbt_parser.fee_amount == fee_amount
+ assert psbt_parser.input_amount == psbt_parser.spend_amount + psbt_parser.change_amount + psbt_parser.fee_amount
+
+ # Now try a single mega psbt with ALL the outputs at once
+ psbt.outputs.clear()
+ change_amount = input_amount - fee_amount
+ for output in PSBTTestData.ALL_EXTERNAL_OUTPUTS:
+ output_amount = random.randint(200_000, int(change_amount / 2))
+ psbt.outputs.append(create_output(output, output_amount))
+ change_amount -= output_amount
+
+ # Don't forget the change!
+ psbt.outputs.append(create_output(change_data, change_amount))
+
+ assert len(psbt.outputs) == len(PSBTTestData.ALL_EXTERNAL_OUTPUTS) + 1
+ psbt_parser = PSBTParser(p=psbt, seed=self.seed, network=SettingsConstants.REGTEST)
+ assert psbt_parser.num_inputs == len(psbt.inputs)
+ assert psbt_parser.input_amount == input_amount
+ assert psbt_parser.num_destinations == len(PSBTTestData.ALL_EXTERNAL_OUTPUTS)
+ assert psbt_parser.num_change_outputs == 1
+ assert psbt_parser.spend_amount == input_amount - change_amount - fee_amount
+ assert psbt_parser.change_amount == change_amount
+ assert psbt_parser.fee_amount == fee_amount
+ assert psbt_parser.input_amount == psbt_parser.spend_amount + psbt_parser.change_amount + psbt_parser.fee_amount
+
+
+ def test_singlesig_native_segwit(self):
+ self.run_basic_test(PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_CHANGE, PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_SELF_TRANSFER)
+
+ def test_singlesig_nested_segwit(self):
+ self.run_basic_test(PSBTTestData.SINGLE_SIG_NESTED_SEGWIT_1_INPUT, PSBTTestData.SINGLE_SIG_NESTED_SEGWIT_CHANGE, PSBTTestData.SINGLE_SIG_NESTED_SEGWIT_SELF_TRANSFER)
+
+ def test_singlesig_taproot(self):
+ self.run_basic_test(PSBTTestData.SINGLE_SIG_TAPROOT_1_INPUT, PSBTTestData.SINGLE_SIG_TAPROOT_CHANGE, PSBTTestData.SINGLE_SIG_TAPROOT_SELF_TRANSFER)
+
+ def test_singlesig_legacy_p2pkh(self):
+ self.run_basic_test(PSBTTestData.SINGLE_SIG_LEGACY_P2PKH_1_INPUT, PSBTTestData.SINGLE_SIG_LEGACY_P2PKH_CHANGE, PSBTTestData.SINGLE_SIG_LEGACY_P2PKH_SELF_TRANSFER)
+
+ def test_multisig_native_segwit(self):
+ self.run_basic_test(PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.MULTISIG_NATIVE_SEGWIT_CHANGE, PSBTTestData.MULTISIG_NATIVE_SEGWIT_SELF_TRANSFER)
+
+ def test_multisig_nested_segwit(self):
+ self.run_basic_test(PSBTTestData.MULTISIG_NESTED_SEGWIT_1_INPUT, PSBTTestData.MULTISIG_NESTED_SEGWIT_CHANGE, PSBTTestData.MULTISIG_NESTED_SEGWIT_SELF_TRANSFER)
+
+ def test_multisig_legacy_p2sh(self):
+ self.run_basic_test(PSBTTestData.MULTISIG_LEGACY_P2SH_1_INPUT, PSBTTestData.MULTISIG_LEGACY_P2SH_CHANGE, PSBTTestData.MULTISIG_LEGACY_P2SH_SELF_TRANSFER)
+
+
+ def test_has_matching_input_fingerprint(self):
+ """
+ PSBTParser should correctly identify when a psbt contains an input that matches a
+ given Seed's fingerprint.
+ """
+ wrong_seed = Seed(["bacon"] * 24)
+ for input in PSBTTestData.ALL_INPUTS:
+ psbt = PSBT.parse(a2b_base64(input))
+ assert PSBTParser.has_matching_input_fingerprint(psbt, PSBTTestData.seed)
+ assert PSBTParser.has_matching_input_fingerprint(psbt, wrong_seed) == False
+
+ # The other keys in the multisig inputs should also match
+ for input in PSBTTestData.MULTISIG_INPUTS:
+ psbt = PSBT.parse(a2b_base64(input))
+ assert PSBTParser.has_matching_input_fingerprint(psbt, PSBTTestData.multisig_key_2)
+ assert PSBTParser.has_matching_input_fingerprint(psbt, PSBTTestData.multisig_key_3)
+
+
+ def test_missing_fingerprint_handling(self):
+ """
+ PSBTParser should correctly handle PSBTs with missing fingerprints (created from XPUB-only imports,
+ without derivation path) by matching public keys against the seed and filling in correct fingerprints.
+ """
+ for input in PSBTTestData.ALL_INPUTS:
+ psbt = PSBT.parse(a2b_base64(input))
+
+ # Set fingerprints to zero to simulate XPUB-only import (missing fingerprint)
+ from embit.psbt import DerivationPath
+ for inp in psbt.inputs:
+ for pub, derivation in inp.bip32_derivations.items():
+ inp.bip32_derivations[pub] = DerivationPath(
+ fingerprint=b"\x00\x00\x00\x00",
+ derivation=derivation.derivation
+ )
+
+ for pub, (leaf_hashes, derivation) in inp.taproot_bip32_derivations.items():
+ inp.taproot_bip32_derivations[pub] = (leaf_hashes, DerivationPath(
+ fingerprint=b"\x00\x00\x00\x00",
+ derivation=derivation.derivation
+ ))
+
+ # Test that has_matching_input_fingerprint can correctly identify that an input
+ # from the psbt does belong to the provided seed, even when the fingerprints
+ # (in the inputs' bip32 derivations) have been zeroed out.
+ assert PSBTParser.has_matching_input_fingerprint(psbt, PSBTTestData.seed, SettingsConstants.REGTEST)
+
+ # Test that it correctly rejects wrong seeds
+ wrong_seed = Seed(["bacon"] * 24)
+ assert not PSBTParser.has_matching_input_fingerprint(psbt, wrong_seed, SettingsConstants.REGTEST)
+
+ # Test the PSBTParser's ability to fill missing fingerprints during parsing
+ parser = PSBTParser(p=psbt, seed=PSBTTestData.seed, network=SettingsConstants.REGTEST)
+
+ # Verify fingerprints were correctly filled after parsing
+ seed_fingerprint = parser.seed.get_fingerprint(SettingsConstants.REGTEST)
+
+ for inp in parser.psbt.inputs:
+ for pub, derivation in inp.bip32_derivations.items():
+ from binascii import hexlify
+ fingerprint_hex = hexlify(derivation.fingerprint).decode()
+
+ # Check if this public key derives from the current seed
+ derived_key = parser.root.derive(derivation.derivation)
+ if derived_key.key.sec() == pub.sec():
+ # This pubkey derives from current seed, should have current seed's fingerprint
+ assert fingerprint_hex == seed_fingerprint, f"Expected {seed_fingerprint}, got {fingerprint_hex} for pubkey that derives from current seed"
+ else:
+ # This pubkey doesn't derive from current seed, should remain 00000000
+ assert fingerprint_hex == "00000000"
+
+ # Also check Taproot derivations
+ for pub, (leaf_hashes, derivation) in inp.taproot_bip32_derivations.items():
+ from binascii import hexlify
+ fingerprint_hex = hexlify(derivation.fingerprint).decode()
+
+ # Check if this public key derives from the current seed
+ derived_key = parser.root.derive(derivation.derivation)
+ if derived_key.key.sec() == pub.sec():
+ # This pubkey derives from current seed, should have current seed's fingerprint
+ assert fingerprint_hex == seed_fingerprint, f"Expected {seed_fingerprint}, got {fingerprint_hex} for taproot pubkey that derives from current seed"
+ else:
+ # This pubkey doesn't derive from current seed, should remain 00000000
+ assert fingerprint_hex == "00000000"
+
+
+ def test_trim_and_sig_count(self):
+ """
+ PSBTParser should correctly trim a psbt of all unnecessary data and count the number of
+ signatures in the psbt.
+ """
+ output = create_output(PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_RECEIVE, 100_000)
+ for input in PSBTTestData.ALL_INPUTS:
+ psbt: PSBT = PSBT.parse(a2b_base64(input))
+ psbt.outputs.append(output)
+ psbt.sign_with(bip32.HDKey.from_seed(self.seed.seed_bytes))
+ assert PSBTParser.sig_count(psbt) == 1
+
+ # TODO: What can we test for before/after trimming?
+ PSBTParser.trim(psbt)
+
+ if input in PSBTTestData.MULTISIG_INPUTS:
+ psbt.sign_with(bip32.HDKey.from_seed(PSBTTestData.multisig_key_2.seed_bytes))
+ assert PSBTParser.sig_count(psbt) == 2
+
+ psbt.sign_with(bip32.HDKey.from_seed(PSBTTestData.multisig_key_3.seed_bytes))
+ assert PSBTParser.sig_count(psbt) == 3
+
+
+ def test_verify_multisig_output(self):
+ """
+ PSBTParser should correctly verify multisig change and self-transfer outputs against the
+ provided descriptor or fail to verify if we provide the wrong descriptor.
+ """
+ multisig_inputs = [
+ PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT,
+ PSBTTestData.MULTISIG_NESTED_SEGWIT_1_INPUT,
+ PSBTTestData.MULTISIG_LEGACY_P2SH_1_INPUT
+ ]
+ change_outputs = [
+ PSBTTestData.MULTISIG_NATIVE_SEGWIT_CHANGE,
+ PSBTTestData.MULTISIG_NESTED_SEGWIT_CHANGE,
+ PSBTTestData.MULTISIG_LEGACY_P2SH_CHANGE
+ ]
+ self_transfer_outputs = [
+ PSBTTestData.MULTISIG_NATIVE_SEGWIT_SELF_TRANSFER,
+ PSBTTestData.MULTISIG_NESTED_SEGWIT_SELF_TRANSFER,
+ PSBTTestData.MULTISIG_LEGACY_P2SH_SELF_TRANSFER
+ ]
+ descriptors = [
+ PSBTTestData.MULTISIG_NATIVE_SEGWIT_DESCRIPTOR,
+ PSBTTestData.MULTISIG_NESTED_SEGWIT_DESCRIPTOR,
+ PSBTTestData.MULTISIG_LEGACY_P2SH_DESCRIPTOR
+ ]
+
+ for i, psbt_base64 in enumerate(multisig_inputs):
+ # Construct a psbt with change & self-transfer outputs of the same type as the input
+ psbt: PSBT = PSBT.parse(a2b_base64(psbt_base64))
+ psbt.outputs.append(create_output(change_outputs[i], 100_000))
+ psbt.outputs.append(create_output(self_transfer_outputs[i], 100_000))
+ psbt_parser = PSBTParser(p=psbt, seed=self.seed, network=SettingsConstants.REGTEST)
+
+ # Attempt to verify the change & self-transfer outputs using the right and wrong descriptors
+ for j, descriptor_str in enumerate(descriptors):
+ descriptor = Descriptor.from_string(descriptor_str.replace("<0;1>", "{0,1}"))
+ if i == j:
+ assert psbt_parser.verify_multisig_output(descriptor, change_num=0) == True
+ assert psbt_parser.verify_multisig_output(descriptor, change_num=1) == True # self-transfer is considered change
+ else:
+ assert psbt_parser.verify_multisig_output(descriptor, change_num=0) == False
+ assert psbt_parser.verify_multisig_output(descriptor, change_num=1) == False
+
+
+
+# TODO: Refactor all tests to be in the TestPSBTParser class(?)
def test_p2tr_change_detection():
""" Should successfully detect change in a p2tr to p2tr psbt spend
@@ -28,7 +305,7 @@ def test_p2tr_change_detection():
psbt_base64 = "cHNidP8BAIkCAAAAAf8upuiIWF1VTgC/Q8ZWRrameRigaXpRcQcBe8ye+TK3AQAAAAAXCgAAAs7BJqsAAAAAIlEgGKqNQ7yF4+yFrrscHnjrbEHiJFExhR903ze43FtOH3BwTgQTAAAAACJRINBe93RcrOYO4UVLLE0y8pzvblOKQWcoQ0obCey8nA5GAAAAAE8BBDWHzwNMUx9OgAAAAJdr+WtwWfVa6IPbpKZ4KgRC0clbm11Gl155IPA27n2FAvQCrFGH6Ac2U0Gcy1IH5f5ltgUBDz2+fe8iqL6JzZdgEDlK7RRWAACAAQAAgAAAAIAAAQB9AgAAAAGAKOOUFIzw9pbRDaZ7F0DYhLImrdMn//OSm++ff5VNdAAAAAAAAQAAAAKsjLwAAAAAABYAFKEcuxvXmB3rWHSqSviP5mrKMZoL2RArvgAAAAAiUSBGU0Lg5fx/ECsB1Z4ZUqXQFSLFnlmpm0rm5R2l599h2AAAAAABASvZECu+AAAAACJRIEZTQuDl/H8QKwHVnhlSpdAVIsWeWambSublHaXn32HYAQMEAAAAACEWF7hZVn7pIDR429kAn/WDeQiWjZey1iGHztsL1H83QLMZADlK7RRWAACAAQAAgAAAAIABAAAAAAAAAAEXIBe4WVZ+6SA0eNvZAJ/1g3kIlo2XstYhh87bC9R/N0CzACEHbJdqWyMxF2eOPr6YRXUJmry04HUbgKyeM2IZeG+NI9AZADlK7RRWAACAAQAAgAAAAIABAAAAAQAAAAEFIGyXalsjMRdnjj6+mEV1CZq8tOB1G4CsnjNiGXhvjSPQAAA="
raw = a2b_base64(psbt_base64)
- tx = psbt.PSBT.parse(raw)
+ tx = PSBT.parse(raw)
mnemonic = "goddess rough corn exclude cream trial fee trumpet million prevent gaze power".split()
pw = ""
@@ -51,6 +328,7 @@ def test_p2tr_change_detection():
+# TODO: Test no longer necessary now that we have exhaustive tests for all types above?
def test_p2sh_legacy_multisig():
"""
Should correctly parse a legacy multisig p2sh (m/45') psbt.
@@ -84,7 +362,7 @@ def test_p2sh_legacy_multisig():
descriptor = Descriptor.from_string("sh(sortedmulti(2,[0f889044/45h]tpubD8NkS3Gngj7L4FJRYrwojKhsx2seBhrNrXVdvqaUyvtVe1YDCVcziZVa9g3KouXz7FN5CkGBkoC16nmNu2HcG9ubTdtCbSW8DEXSMHmmu62/<0;1>/*,[03cd0a2b/45h]tpubD8HkLLgkdJkVitn1i9CN4HpFKJdom48iKm9PyiXYz5hivn1cGz6H3VeS6ncmCEgamvzQA2Qofu2YSTwWzvuaYWbJDEnvTUtj5R96vACdV6L/<0;1>/*,[769f695c/45h]tpubD98hRDKvtATTM8hy5Vvt5ZrvDXwJvrUZm1p1mTKDmd7FqUHY9Wj2k4X1CvxjjtTf3JoChWqYbnWjfkRJ65GQnpVJKbbMfjnGzCwoBUXafyM/<0;1>/*))#uardwtq4".replace("<0;1>", "{0,1}"))
psbt_base64 = "cHNidP8BALsCAAAAAk/6v0Yo0tvQSd45NaCoZQj0dS2RU35cF+KXp/RbBltsAAAAAAD9////HN9jZsT3CVXquPrSgGg7/H8DHsy18Ej8uCqaAo8UAsQAAAAAAP3///8DWeYAAAAAAAAXqRTsNEZFrVtk15AU60/MeTWjxGCZJIeQXwEAAAAAABepFIgB1fOQz3ajeGClCsf7Kn4BDG1Zh1DDAAAAAAAAFgAUOCnLFF5fXnue7LTJBtJne7SfW4xlCgAATwEENYfPAQPNCiuAAAAtoPXmwca4wIkJmJbT0l8IJkQoZyf1a0Hf3l3/y+P9YLsCb3zYh0WQQHK0NeKTHOh4tXmreSkeD5t+ayaPudyvWWAIA80KKy0AAIBPAQQ1h88BD4iQRIAAAC1xQDAuEKWgk+mzBHCEZ3Ibco/WRjRUB61ToV0CY2upCgMoWAP8JdgKLlkerHgciZglm2jGmPHrQqLuS8rgRqfwWQgPiJBELQAAgE8BBDWHzwF2n2lcgAAALXtkfUG4BFcO0mnNEFWpGBBvebmUn9Icjd9KVpKJF/MkA59Hw6Sxmpk0lp7SYIoBZJ8BFT3IVY9Ywu6NVn2JGfLmCHafaVwtAACAAAEAUwIAAAABLEtmpDrExA4GJ2itUuWqHQqVsr0WoamuwxKxFA+if3oDAAAAAP3///8BvIUBAAAAAAAXqRSO3FlqUGy1+B6q4UZU1uvY6aDX7YdkCgAAAQMEAQAAAAEEaVIhAhV0XDrvBSAO2pnyRtuyioVgPwb9fxQ7GwNSYKODA6XIIQKHsTdUi0B81JZaK9WASeMWb1ad2snk9iPJ8KKYGJDS+CEC6k1h+lULPMlXOd0x4bIBUwpoTr30vFfoHqr3gSKmlnlTriIGAoexN1SLQHzUllor1YBJ4xZvVp3ayeT2I8nwopgYkNL4EAPNCistAACAAAAAAAQAAAAiBgLqTWH6VQs8yVc53THhsgFTCmhOvfS8V+geqveBIqaWeRAPiJBELQAAgAAAAAAEAAAAIgYCFXRcOu8FIA7amfJG27KKhWA/Bv1/FDsbA1Jgo4MDpcgQdp9pXC0AAIAAAAAABAAAAAABAFMCAAAAASxLZqQ6xMQOBidorVLlqh0KlbK9FqGprsMSsRQPon96BAAAAAD9////ATGGAQAAAAAAF6kU7vgoQJrHpHs0uEBUzW4ogkY3VmuHYwoAAAEDBAEAAAABBGlSIQJMzyIV0BhlIAdtCFRC0nWcJ+qiowFHgStyQvx/Ov9lYSECo3z9DGK1zjn25m1n8NHEoQlcNOnsnF5UA2khAfUhxTUhA9IpGx2/u34tqOV/jRErjSguk6uQK3L743i2LgKpXB+VU64iBgJMzyIV0BhlIAdtCFRC0nWcJ+qiowFHgStyQvx/Ov9lYRADzQorLQAAgAAAAAADAAAAIgYD0ikbHb+7fi2o5X+NESuNKC6Tq5ArcvvjeLYuAqlcH5UQD4iQRC0AAIAAAAAAAwAAACIGAqN8/Qxitc459uZtZ/DRxKEJXDTp7JxeVANpIQH1IcU1EHafaVwtAACAAAAAAAMAAAAAAQBpUiEC7j3OSch6J9P+ZAcOiGeZ4Be3wS4zjzXyU6EzwixfEqQhAxzm3beiYzYmSxMsG0XD5jHoUCvBVSJtRvw41z1X+eT/IQMdnm4JRBPcOlCFGPcpryOjWzlDynm6+8Va+rYxWV5cz1OuIgIDHZ5uCUQT3DpQhRj3Ka8jo1s5Q8p5uvvFWvq2MVleXM8QA80KKy0AAIABAAAAAAAAACICAxzm3beiYzYmSxMsG0XD5jHoUCvBVSJtRvw41z1X+eT/EA+IkEQtAACAAQAAAAAAAAAiAgLuPc5JyHon0/5kBw6IZ5ngF7fBLjOPNfJToTPCLF8SpBB2n2lcLQAAgAEAAAAAAAAAAAEAaVIhAoETdqS+0tZtmj0auNDI9SxxCmUw5Iq9JJjvWjrpPGOCIQKD7KrnsR4fGz0vM67hRh17r9WznwE4JfSEJxSdJMVopyEDvLJhv9fUi2uoUAQN9AQ7fYeUFJMa/iRw2jKBYDn04zpTriICAoPsquexHh8bPS8zruFGHXuv1bOfATgl9IQnFJ0kxWinEAPNCistAACAAAAAAAUAAAAiAgKBE3akvtLWbZo9GrjQyPUscQplMOSKvSSY71o66TxjghAPiJBELQAAgAAAAAAFAAAAIgIDvLJhv9fUi2uoUAQN9AQ7fYeUFJMa/iRw2jKBYDn04zoQdp9pXC0AAIAAAAAABQAAAAAA"
raw = a2b_base64(psbt_base64)
- tx = psbt.PSBT.parse(raw)
+ tx = PSBT.parse(raw)
# 03cd0a2b test seed
mnemonic = "better gown govern speak spawn vendor exercise item uncle odor sound cat".split()
@@ -113,6 +391,8 @@ def test_p2sh_legacy_multisig():
assert psbt_parser.verify_multisig_output(descriptor, 1)
+
+# TODO: Test no longer necessary now that we have exhaustive tests for all types above?
def test_p2sh_p2wpkh_nested_segwit():
"""
Should correctly parse a nested segwit (m/49'/1'/0') psbt.
@@ -140,7 +420,7 @@ def test_p2sh_p2wpkh_nested_segwit():
descriptor = Descriptor.from_string("sh(wpkh([c751dc07/49h/1h/0h]tpubDDS23bf7c9mdfWpuvA61HHCYDusq25UtMNYsFagKPNMNWHSm8bvwmNNP2KSpivN3gQWAK8fhDFk3dzgoBn9rPoMncKxJuqNAv7sJMShbZ6i/<0;1>/*))#7sn8gf37".replace("<0;1>", "{0,1}"))
psbt_base64 = "cHNidP8BAH4CAAAAAXfY5crHl+bXtTvKvdo2MaFQeIXw+P+3kzZwBRgw84lFAQAAAAD9////AhjaAAAAAAAAF6kUSop8lEmO4FB1AyV1GJe2bygA7ASHSGsBAAAAAAAiACCHttDIHkeECumMJgZ2643Hhd8rXFnv0ZbDSYM8esN9UIouEwBPAQQ1h88Dv3UWAIAAAACfHgAYuw3ODwXCSP0valI9edAB1t3EInR2TXkbOd+F+AJgmJs8XUkZD5zQAgd3+/ijOqVphlWUMzxDnRorBQYEgxDHUdwHMQAAgAEAAIAAAACAAAEBIBFGAgAAAAAAF6kU7ijES3iWT8u0+44/blPlLfh9WkyHAQMEAQAAAAEEFgAUX7JspW1r0gC+WkUHwGABJ8DU9f8iBgO1/adRC+r8XJ/bjnfdwk3740n0m8gE3+xN8GHsNrxDUxjHUdwHMQAAgAEAAIAAAACAAQAAAAAAAAAAAQAWABT8V9vY29XR8niVYdVSF9H4zRTAbiICArH6DjPShnzXiaAnc2BR1f61QQliH0BOhqAvksByf3e9GMdR3AcxAACAAQAAgAAAAIABAAAAAQAAAAAA"
raw = a2b_base64(psbt_base64)
- tx = psbt.PSBT.parse(raw)
+ tx = PSBT.parse(raw)
# 03cd0a2b test seed
mnemonic = "goddess rough corn exclude cream trial fee trumpet million prevent gaze power".split()
@@ -159,6 +439,10 @@ def test_p2sh_p2wpkh_nested_segwit():
assert psbt_parser.get_change_data(0)['address'] == '2Mz3MthXyM4YDjLPw1V4PAacKt4pD8Cz8N3'
assert psbt_parser.get_change_data(0)["amount"] == 55832
+ # We should be able to verify the change addr
+ assert psbt_parser.verify_multisig_output(descriptor, 0)
+
+
def test_parse_op_return_content():
"""
@@ -177,7 +461,7 @@ def test_parse_op_return_content():
psbt_base64 = "cHNidP8BAIYCAAAAATpQ10o+gKdZ8ThpKsbfHiHYn3NhvUrQ5DvW0ZWX8jKLAAAAAAD9////AujC9QUAAAAAFgAUY61+2BcXt+tsWoxV1nVw20kVb1UAAAAAAAAAACtqTChDaGFuY2VsbG9yIG9uIHRoZSBicmluayBvZiB0aGlyZCBiYWlsb3V0aQAAAE8BBDWHzwNXmUmVgAAAANRFa7R5gYD84Wbha3d1QnjgfYPOBw87on6cXS32WoyqAsPFtPxB7PRTdbujUnBPUVDh9YUBtwrl4nc0OcRNGvIyEA+4gv9UAACAAQAAgAAAAIAAAQB0AgAAAAGNFK/1X0fP5q+nu5XX7Tk2VRa0EL+jkGI9CHiJvsjZCgAAAAAA/f///wKMw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAAAAAAAAAAAZakwWYml0Y29pbiBpcyBmcmVlIHNwZWVjaGgAAAABAR+Mw/UFAAAAABYAFIpZMNnUU6cQt8Q0YpZ0pnvsSA5fAQMEAQAAACIGAvxDI0eNI1oQ2AU69R7A0jf+hUdilWCgrWHgdzkqlaXMGA+4gv9UAACAAQAAgAAAAIAAAAAAAQAAAAAiAgK9qKtzGWyiRrpmupdA99NVLriz3GQy6cENbyD19sfl/hgPuIL/VAAAgAEAAIAAAACAAAAAAAIAAAAAAA=="
raw = a2b_base64(psbt_base64)
- tx = psbt.PSBT.parse(raw)
+ tx = PSBT.parse(raw)
mnemonic = "model ensure search plunge galaxy firm exclude brain satoshi meadow cable roast".split()
pw = ""
diff --git a/tests/test_settings.py b/tests/test_settings.py
index 94ac626a..72a7ab5c 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -1,7 +1,10 @@
+import json
import pytest
+
from base import BaseTest
+
from seedsigner.models.settings import InvalidSettingsQRData, Settings
-from seedsigner.models.settings_definition import SettingsConstants
+from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
@@ -14,6 +17,10 @@ class TestSettings(BaseTest):
def test_reset_settings(self):
""" BaseTest.reset_settings() should wipe out any previous Settings changes """
+ settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS)
+ assert settings_entry.default_value == SettingsConstants.OPTION__DISABLED
+
+ # Change the setting from its default
settings = Settings.get_instance()
settings.set_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS, SettingsConstants.OPTION__ENABLED)
assert settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__ENABLED
@@ -23,18 +30,110 @@ class TestSettings(BaseTest):
assert settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED
+ def test_settings_defaults(self):
+ """ Settings should initialize to their default values """
+ BaseTest.reset_settings()
+ settings = Settings.get_instance()
+ for settings_entry in SettingsDefinition.get_settings_entries():
+ assert settings.get_value(settings_entry.attr_name) == settings_entry.default_value
+
+
+ def test_load_persistent_settings(self):
+ """ Settings should load previously saved persistent settings from disk, if any
+ exist. """
+ # Initial Settings will start with defaults
+ settings = Settings.get_instance()
+
+ # Enable persistent settings and make another change
+ settings.set_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS, SettingsConstants.OPTION__ENABLED)
+
+ assert settings.get_value(SettingsConstants.SETTING__QR_DENSITY) != SettingsConstants.DENSITY__HIGH
+ settings.set_value(SettingsConstants.SETTING__QR_DENSITY, SettingsConstants.DENSITY__HIGH)
+
+ # Hold on to the settings.json content
+ settings_json = None
+ with open(Settings.SETTINGS_FILENAME) as settings_file:
+ settings_json = json.loads(settings_file.read())
+
+ # Now wipe out the Settings singleton
+ BaseTest.reset_settings()
+
+ # This also deletes settings.json, so recreate it
+ with open(Settings.SETTINGS_FILENAME, "w") as settings_file:
+ settings_file.write(json.dumps(settings_json))
+
+ # Now instantiate the Settings singleton again; it should load from disk
+ settings = Settings.get_instance()
+
+ # Persistent setting change should have survived
+ assert settings.get_value(SettingsConstants.SETTING__QR_DENSITY) == SettingsConstants.DENSITY__HIGH
+
+
+ def test_load_empty_multiselect_settings(self):
+ """ Empty multiselect settings should load defaults. """
+ # Initial Settings will start with defaults
+ settings = Settings.get_instance()
+
+ # Enable persistent settings to write settings.json to disk
+ settings.set_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS, SettingsConstants.OPTION__ENABLED)
+
+ # Hold on to the settings.json content
+ settings_dict = None
+ with open(Settings.SETTINGS_FILENAME) as settings_file:
+ settings_dict = json.loads(settings_file.read())
+
+ def _verify_defaults_loaded(attr_name: str):
+ # Verify that the multiselect setting has loaded its defaults
+ settings = Settings.get_instance()
+ cur_setting_value = settings.get_value(attr_name)
+ assert cur_setting_value == SettingsDefinition.get_settings_entry(attr_name).default_value
+
+ # Alter the settings to test against various empty values
+ for empty_value in ["", ",", [], None]:
+ settings_dict[SettingsConstants.SETTING__SIG_TYPES] = empty_value
+ settings.update(settings_dict)
+ _verify_defaults_loaded(SettingsConstants.SETTING__SIG_TYPES)
+
+ # One last test: remove the multiselect setting entirely
+ del settings_dict[SettingsConstants.SETTING__SIG_TYPES]
+ settings.update(settings_dict)
+ _verify_defaults_loaded(SettingsConstants.SETTING__SIG_TYPES)
+
+
+
+class SettingsQRBase(BaseTest):
+ """
+ Reusable base test class for testing SettingsQR data.
+ """
+ def setup_method(self):
+ super().setup_method()
+ settingsqr_dict = SettingsDefinition.get_defaults(use_abbreviated_name=True, skip_hidden=True)
+
+ # Build baseline SettingsQR config based on defaults
+ self.settingsqr_prefix = "settings::v1"
+ self.settingsqr_default_attrs_str = ""
+ for abbreviated_attr_name, value in settingsqr_dict.items():
+ if isinstance(value, list):
+ value_str = ",".join(value)
+ else:
+ value_str = str(value)
+ self.settingsqr_default_attrs_str += f" {abbreviated_attr_name}={value_str}"
+
+
+
+class TestSettingsQRParser(SettingsQRBase):
def test_parse_settingsqr_data(self):
"""
SettingsQR parser should successfully parse a valid settingsqr input string and
return the resulting config_name and formatted settings_update_dict.
"""
settings_name = "Test SettingsQR"
- settingsqr_data = f"""settings::v1 name={ settings_name.replace(" ", "_") } persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss,ms scripts=nat,nes,tr xpub_details=E passphrase=E camera=180 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"""
+ settingsqr_data = f"""{self.settingsqr_prefix} name={ settings_name.replace(" ", "_") } {self.settingsqr_default_attrs_str}"""
# First explicitly set settings that differ from the settingsqr_data
self.settings.set_value(SettingsConstants.SETTING__COMPACT_SEEDQR, SettingsConstants.OPTION__DISABLED)
self.settings.set_value(SettingsConstants.SETTING__DIRE_WARNINGS, SettingsConstants.OPTION__DISABLED)
- self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [SettingsConstants.COORDINATOR__BLUE_WALLET, SettingsConstants.COORDINATOR__SPARROW])
+ self.settings.set_value(SettingsConstants.SETTING__XPUB_QR_FORMAT, [SettingsConstants.XPUB_QR_FORMAT__STATIC, SettingsConstants.XPUB_QR_FORMAT__SPECTER_LEGACY])
# Now parse the settingsqr_data
config_name, settings_update_dict = Settings.parse_settingsqr(settingsqr_data)
@@ -45,43 +144,45 @@ class TestSettings(BaseTest):
assert self.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__ENABLED
assert self.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED
- coordinators = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)
- assert SettingsConstants.COORDINATOR__BLUE_WALLET not in coordinators
- assert SettingsConstants.COORDINATOR__SPARROW in coordinators
- assert SettingsConstants.COORDINATOR__SPECTER_DESKTOP in coordinators
+ xpub_qr_formats = self.settings.get_value(SettingsConstants.SETTING__XPUB_QR_FORMAT)
+ assert SettingsConstants.XPUB_QR_FORMAT__UR_CRYPTO_ACCOUNT in xpub_qr_formats
+ assert SettingsConstants.XPUB_QR_FORMAT__STATIC in xpub_qr_formats
+ assert SettingsConstants.XPUB_QR_FORMAT__SPECTER_LEGACY not in xpub_qr_formats
def test_settingsqr_version(self):
""" SettingsQR parser should accept SettingsQR v1 and reject any others """
- settingsqr_data = "settings::v1 name=Foo"
+ settingsqr_data = f"{self.settingsqr_prefix} {self.settingsqr_default_attrs_str}"
config_name, settings_update_dict = Settings.parse_settingsqr(settingsqr_data)
# Accepts update with no Exceptions
self.settings.update(new_settings=settings_update_dict)
- settingsqr_data = "settings::v2 name=Foo"
+ settingsqr_data = f"settings::v2 {self.settingsqr_default_attrs_str}"
with pytest.raises(InvalidSettingsQRData) as e:
Settings.parse_settingsqr(settingsqr_data)
assert "Unsupported SettingsQR version" in str(e.value)
# Should also fail if version omitted
- settingsqr_data = "settings name=Foo"
+ settingsqr_data = f"settings {self.settingsqr_default_attrs_str}"
with pytest.raises(InvalidSettingsQRData) as e:
Settings.parse_settingsqr(settingsqr_data)
# And if "settings" is omitted entirely
- settingsqr_data = "name=Foo"
+ settingsqr_data = self.settingsqr_default_attrs_str
with pytest.raises(InvalidSettingsQRData) as e:
Settings.parse_settingsqr(settingsqr_data)
def test_settingsqr_ignores_unrecognized_setting(self):
""" SettingsQR parser should ignore unrecognized settings """
- settingsqr_data = "settings::v1 name=Foo favorite_food=bacon xpub_export=D"
+ settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__NETWORK)
+ unknown_attr = "favorite_food"
+ settingsqr_data = f"{self.settingsqr_prefix} {unknown_attr}=bacon {settings_entry.abbreviated_name}={settings_entry.default_value}"
config_name, settings_update_dict = Settings.parse_settingsqr(settingsqr_data)
- assert "favorite_food" not in settings_update_dict
- assert "xpub_export" in settings_update_dict
+ assert unknown_attr not in settings_update_dict
+ assert settings_entry.attr_name in settings_update_dict
# Accepts update with no Exceptions
self.settings.update(new_settings=settings_update_dict)
@@ -89,18 +190,28 @@ class TestSettings(BaseTest):
def test_settingsqr_fails_unrecognized_option(self):
""" SettingsQR parser should fail if a settings has an unrecognized option """
- settingsqr_data = "settings::v1 name=Foo xpub_export=Yep"
+ settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__NETWORK)
+ settingsqr_data = f"{self.settingsqr_prefix} {settings_entry.abbreviated_name}=fake_value"
with pytest.raises(InvalidSettingsQRData) as e:
Settings.parse_settingsqr(settingsqr_data)
- assert "xpub_export" in str(e.value)
+ assert settings_entry.attr_name in str(e.value)
+
+
+ def test_settingsqr_fails_empty_values(self):
+ """ SettingsQR parser should fail if a setting is empty """
+ settingsqr_data = "settings::v1 persistent=D sigs= camera=180"
+ with pytest.raises(InvalidSettingsQRData) as e:
+ Settings.parse_settingsqr(settingsqr_data)
+ assert "sigs" in str(e.value)
def test_settingsqr_parses_line_break_separators(self):
""" SettingsQR parser should read line breaks as acceptable separators """
- settingsqr_data = "settings::v1\nname=Foo\nsigs=ss,ms\nscripts=nat,nes,tr\nxpub_export=E\n"
+ attrs_with_line_breaks = self.settingsqr_default_attrs_str.replace(' ', '\n')
+ settingsqr_data = f"{self.settingsqr_prefix}\n{attrs_with_line_breaks}"
config_name, settings_update_dict = Settings.parse_settingsqr(settingsqr_data)
- assert len(settings_update_dict.keys()) == 3
+ assert len(settings_update_dict.keys()) == self.settingsqr_default_attrs_str.count('=')
# Accepts update with no Exceptions
self.settings.update(new_settings=settings_update_dict)
diff --git a/tests/test_settings_definition.py b/tests/test_settings_definition.py
new file mode 100644
index 00000000..794bdba0
--- /dev/null
+++ b/tests/test_settings_definition.py
@@ -0,0 +1,37 @@
+import os
+from unittest.mock import patch
+
+from base import BaseTest
+from seedsigner.models.settings_definition import SettingsConstants
+
+
+class TestSettingsDefinition(BaseTest):
+ @classmethod
+ def setup_class(cls):
+ super().setup_class()
+
+
+ def test__get_detected_languages(self):
+ """ Should auto-detect onboard languages based on the supported locales list """
+ detected_languages = [lang_tuple[0] for lang_tuple in SettingsConstants.get_detected_languages()]
+
+ # Find an unused language code; avoiding hard coding a language code to keep
+ # this test future proof.
+ absent_language_code = None
+ for language_code in SettingsConstants.ALL_LOCALES.keys():
+ if language_code not in detected_languages:
+ absent_language_code = language_code
+ break
+
+ # Should only fail if we've absolutely crushed the global translations!!!
+ assert absent_language_code is not None
+
+ root = os.path.join(os.getcwd(), "src", "seedsigner", "resources", "seedsigner-translations", "l10n")
+
+ # We're going to mock the `root` results to include the absent language code's .mo file
+ mocked_results = [(os.path.join(root, "en", "LC_MESSAGES"), [], ["messages.po", "messages.mo"])]
+ mocked_results.append((os.path.join(root, absent_language_code, "LC_MESSAGES"), [], ["messages.po", "messages.mo"]))
+ with patch("os.walk", return_value=mocked_results):
+ # Recheck w/our mocked dir listing:
+ detected_languages = [lang_tuple[0] for lang_tuple in SettingsConstants.get_detected_languages()]
+ assert absent_language_code in detected_languages
diff --git a/tests/test_settingsqr_decoder.py b/tests/test_settingsqr_decoder.py
index bb124c4f..7b40c927 100644
--- a/tests/test_settingsqr_decoder.py
+++ b/tests/test_settingsqr_decoder.py
@@ -1,15 +1,17 @@
+from test_settings import SettingsQRBase
from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus
-class TestSettingsQRDecoder:
+
+class TestSettingsQRDecoder(SettingsQRBase):
def test_decode_settingsqr(self):
"""
Assume the QR reader decodes the SettingsQR content correctly and begin this test
with parsing the result.
"""
settings_name = "Test SettingsQR"
- settings_qr_str = f"""settings::v1 name={ settings_name.replace(" ", "_") } persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss,ms scripts=nat,nes,tr xpub_details=E passphrase=E camera=180 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"""
+ settings_qr_str = f"""{self.settingsqr_prefix} name={ settings_name.replace(" ", "_") } {self.settingsqr_default_attrs_str}"""
# Now parse the settings_qr_str
decoder = DecodeQR()