diff --git a/.github/workflows/telegram.yml b/.github/workflows/telegram.yml deleted file mode 100644 index 78b506d3..00000000 --- a/.github/workflows/telegram.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: GitHub Notify on Telegram -on: - pull_request_target: - branches: - - dev - types: - - closed - -jobs: - if_merged: - if: github.event.pull_request.merged == true - runs-on: ubuntu-latest - steps: - - name: Notify on Telegram - uses: EverythingSuckz/github-telegram-notify@main - with: - bot_token: '${{ secrets.BOT_TOKEN }}' - chat_id: '${{ secrets.CHAT_ID }}' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9be339c0..c1e14c97 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,9 +2,6 @@ name: CI on: push: - branches: - - dev - - main pull_request: concurrency: diff --git a/README.md b/README.md index 5c0329ed..171bd671 100644 --- a/README.md +++ b/README.md @@ -111,24 +111,25 @@ Instructions to build a SeedSigner OS image (using precisely the same process th ## Downloading the Software -Download the current Version (0.8.5) software image that is compatible with your Raspberry Pi Hardware. The Pi Zero 1.3 is the most common and recommended board. - + +Download the current Version (0.8.6) software image that is compatible with your Raspberry Pi Hardware. The Pi Zero 1.3 is the most common and recommended board. | Board | Download Image Link/Name | | --------------------- | --------------------------------- | -|**[Raspberry Pi Zero 1.3](https://www.raspberrypi.com/products/raspberry-pi-zero/)** |[`seedsigner_os.0.8.5.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.5/seedsigner_os.0.8.5.pi0.img) | -|[Raspberry Pi Zero W](https://www.raspberrypi.com/products/raspberry-pi-zero-w/) |[`seedsigner_os.0.8.5.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.5/seedsigner_os.0.8.5.pi0.img) | -|[Raspberry Pi Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/) |[`seedsigner_os.0.8.5.pi02w.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.5/seedsigner_os.0.8.5.pi02w.img) | -|[Raspberry Pi 1 Model B/B+](https://www.raspberrypi.com/products/raspberry-pi-1-model-b-plus/) |[`seedsigner_os.0.8.5.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.5/seedsigner_os.0.8.5.pi0.img) | -|[Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) |[`seedsigner_os.0.8.5.pi2.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.5/seedsigner_os.0.8.5.pi2.img) | -|[Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) |[`seedsigner_os.0.8.5.pi02w.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.5/seedsigner_os.0.8.5.pi02w.img) | -|[Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) |[`seedsigner_os.0.8.5.pi4.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.5/seedsigner_os.0.8.5.pi4.img) | -|[Raspberry Pi 400](https://www.raspberrypi.com/products/raspberry-pi-400-unit/) |[`seedsigner_os.0.8.5.pi4.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.5/seedsigner_os.0.8.5.pi4.img) | +|**[Raspberry Pi Zero 1.3](https://www.raspberrypi.com/products/raspberry-pi-zero/)** |[`seedsigner_os.0.8.6.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi0.img) | +|[Raspberry Pi Zero W](https://www.raspberrypi.com/products/raspberry-pi-zero-w/) |[`seedsigner_os.0.8.6.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi0.img) | +|[Raspberry Pi Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/) |[`seedsigner_os.0.8.6.pi02w.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi02w.img) | +|[Raspberry Pi 1 Model B/B+](https://www.raspberrypi.com/products/raspberry-pi-1-model-b-plus/) |[`seedsigner_os.0.8.6.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi0.img) | +|[Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) |[`seedsigner_os.0.8.6.pi2.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi2.img) | +|[Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) |[`seedsigner_os.0.8.6.pi02w.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi02w.img) | +|[Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) |[`seedsigner_os.0.8.6.pi4.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi4.img) | +|[Raspberry Pi 400](https://www.raspberrypi.com/products/raspberry-pi-400-unit/) |[`seedsigner_os.0.8.6.pi4.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi4.img) | Note: If you have physically removed the WiFi component from your board, you will still use the image file of the original (un-modified) hardware. (Our files are compiled/based on the *processor* architecture). Although it is better to spend a few minutes upfront to determine which specific Pi hardware/model you have, if you are still unsure which hardware you have, you can try using the pi0.img file. Making an incorrect choice here will not ruin your board, because this is software, not firmware. -**Also download** these 2 signature verification files to the same folder: -[The Plaintext manifest file](https://github.com/SeedSigner/seedsigner/releases/download/0.8.5/seedsigner.0.8.5.sha256.txt) -[The Signature of the manifest file](https://github.com/SeedSigner/seedsigner/releases/download/0.8.5/seedsigner.0.8.5.sha256.txt.sig) +**Also download** these 2 signature verification files to the same folder +[The Plaintext manifest file](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner.0.8.6.sha256.txt) +[The Signature of the manifest file](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner.0.8.6.sha256.txt.sig) + Users familiar with older versions of the SeedSigner software might be surprised with how fast their software downloads now are, because since version 0.6.0 the software image files are now 100x smaller! Each image file is now under 42 Megabytes so your downloads and verifications will be very quick now (and might even seem *too* quick)! @@ -159,7 +160,7 @@ The result should confirm that 1 key was *either* imported or updated. *Ignore* Next, you will run the *verify* command on the signature (.sig) file. (*Verify* must be run from inside the same folder that you downloaded the files into earlier.) ``` -gpg --verify seedsigner.0.8.5.sha256.txt.sig +gpg --verify seedsigner.0.8.6.sha256.txt.sig ``` When the verify command completes successfully, it should display output like this: @@ -229,21 +230,22 @@ Now that you have confirmed that you do have the real SeedSigner Project's Publi **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.6.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.6.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.6.[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.6.[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. diff --git a/docs/raspberry_pi_os_build_instructions.md b/docs/raspberry_pi_os_build_instructions.md index ec975d6c..32416847 100644 --- a/docs/raspberry_pi_os_build_instructions.md +++ b/docs/raspberry_pi_os_build_instructions.md @@ -75,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 diff --git a/docs/seed_qr/README.md b/docs/seed_qr/README.md index 250bfaa0..e9974395 100644 --- a/docs/seed_qr/README.md +++ b/docs/seed_qr/README.md @@ -22,6 +22,8 @@ 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. +**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 may result in incorrect or invalid seeds. + 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. For example, "tomato" is the 1,825th word in the list. diff --git a/l10n/messages.pot b/l10n/messages.pot index 666c12cc..0a77e16a 100644 --- a/l10n/messages.pot +++ b/l10n/messages.pot @@ -6,9 +6,9 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: seedsigner 0.8.5-rc1\n" +"Project-Id-Version: seedsigner 0.8.5\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2025-01-28 13:28-0600\n" +"POT-Creation-Date: 2025-05-20 09:27-0500\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -319,7 +319,6 @@ msgstr "" msgid "Derivation" msgstr "" -#. Short for "BIP32 Extended Public Key" #: src/seedsigner/gui/screens/seed_screens.py msgid "Xpub" msgstr "" @@ -593,6 +592,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 "" @@ -790,6 +794,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 "" @@ -1254,11 +1269,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 @@ -1291,11 +1306,15 @@ msgid "Signing messages for custom derivation paths not supported" msgstr "" #: src/seedsigner/views/settings_views.py -msgid "I/O test" +msgid "Advanced" msgstr "" #: src/seedsigner/views/settings_views.py -msgid "Advanced" +msgid "Hardware" +msgstr "" + +#: src/seedsigner/views/settings_views.py +msgid "I/O test" msgstr "" #: src/seedsigner/views/settings_views.py @@ -1399,11 +1418,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 "" diff --git a/pyproject.toml b/pyproject.toml index 7f34e326..e6f08308 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.6" [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] @@ -48,8 +48,9 @@ omit = [ "*/__init__.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..d7a20d59 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ embit==0.8.0 Pillow==10.0.1 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..d8a3467a 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -99,7 +99,7 @@ class Controller(Singleton): rather than at the top in order avoid circular imports. """ - VERSION = "0.8.5" + VERSION = "0.8.6" # Declare class member vars with type hints to enable richer IDE support throughout # the code. diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index 6c0f68ae..2c9dfffb 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -48,31 +48,32 @@ 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__CHINESE_TRADITIONAL: "NotoSansTC-Regular", + SettingsConstants.LOCALE__JAPANESE: "NotoSansJP-Regular", + SettingsConstants.LOCALE__KOREAN: "NotoSansKR-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__JAPANESE: 22, # Titles won't render below 22px + SettingsConstants.LOCALE__KOREAN: 23, # Titles won't render below 23px + SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: 23, # Some chars 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__JAPANESE: 18, + SettingsConstants.LOCALE__KOREAN: 18, + SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: 18, } BODY_FONT_MAX_SIZE = TOP_NAV_TITLE_FONT_SIZE["default"] BODY_FONT_MIN_SIZE = 15 @@ -85,18 +86,14 @@ class GUIConstants: 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, + SettingsConstants.LOCALE__JAPANESE: 20, + SettingsConstants.LOCALE__KOREAN: 20, + SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: 20, } BUTTON_FONT_COLOR = "#FCFCFC" BUTTON_BACKGROUND_COLOR = "#2C2C2C" @@ -107,8 +104,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 +114,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 +142,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 +152,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: @@ -431,10 +432,6 @@ 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 @@ -474,9 +471,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: @@ -581,17 +578,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( @@ -1359,8 +1352,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 @@ -1673,8 +1670,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 +1688,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 @@ -1833,24 +1833,31 @@ 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, + ] + if treat_chars_as_words: + # Relax UI constraints even if the result isn't optimal + allow_text_overflow = True # 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 +1865,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 +1875,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: + if len(text.split()) == 1 and not allow_text_overflow and not treat_chars_as_words: # No whitespace chars to split on! raise TextDoesNotFitException("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 @@ -1950,3 +1970,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/renderer.py b/src/seedsigner/gui/renderer.py index db0a4eb5..9ec58daf 100644 --- a/src/seedsigner/gui/renderer.py +++ b/src/seedsigner/gui/renderer.py @@ -1,7 +1,11 @@ from PIL import Image, ImageDraw from threading import Lock -from seedsigner.hardware.ST7789 import ST7789 +# from seedsigner.hardware.st7789_mpy import ST7789 +from seedsigner.hardware.displays.display_driver import ALL_DISPLAY_TYPES, DISPLAY_TYPE__ILI9341, DISPLAY_TYPE__ILI9486, DISPLAY_TYPE__ST7789, DisplayDriver +from seedsigner.hardware.displays.ili9341 import ILI9341, ILI9341_TFTWIDTH, ILI9341_TFTHEIGHT +from seedsigner.models.settings import Settings +from seedsigner.models.settings_definition import SettingsConstants from seedsigner.models.singleton import ConfigurableSingleton @@ -22,19 +26,44 @@ class Renderer(ConfigurableSingleton): 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") + self.disp = DisplayDriver(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 +75,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 +109,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/scan_screens.py b/src/seedsigner/gui/screens/scan_screens.py index c030fef4..c54fa576 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 @@ -132,11 +132,8 @@ class ScanScreen(BaseScreen): scan_text += f" {cur_fps:0.2f} | {self.decoder_fps}" 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 @@ -287,5 +284,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..120614ab 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,8 @@ 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 @@ -286,15 +289,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 +330,37 @@ 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 type(button_option) != ButtonOption: raise Exception("Refactor to ButtonOption approach needed!") + # 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_option.button_label), # Wrap here for just-in-time translations + active_text=_(button_option.active_button_label), # Wrap here for just-in-time translations + 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 ) @@ -553,8 +558,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 +571,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 +579,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: diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index 08d1966b..e390fa15 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -31,8 +31,17 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen): self.possible_alphabet = "abcdefghijklmnopqrstuvwxyz" + # Measure the width required to display the longest word in the English bip39 + # 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 @@ -78,7 +87,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) @@ -634,12 +643,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, @@ -997,10 +1011,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 @@ -1181,68 +1195,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 +1278,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 +1288,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 diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py index c41eadfc..10e8bd06 100644 --- a/src/seedsigner/gui/screens/settings_screens.py +++ b/src/seedsigner/gui/screens/settings_screens.py @@ -21,7 +21,6 @@ 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") diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py index 8fab9a16..57015368 100644 --- a/src/seedsigner/gui/screens/tools_screens.py +++ b/src/seedsigner/gui/screens/tools_screens.py @@ -4,10 +4,11 @@ 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 @@ -19,7 +20,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 +42,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 +81,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 +99,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), @@ -183,9 +210,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), )) @@ -238,13 +267,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, 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 +304,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 +331,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 +380,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, @@ -460,3 +494,47 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen): 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/hardware/ST7789.py b/src/seedsigner/hardware/displays/ST7789.py similarity index 93% rename from src/seedsigner/hardware/ST7789.py rename to src/seedsigner/hardware/displays/ST7789.py index ebd4952b..67d91755 100644 --- a/src/seedsigner/hardware/ST7789.py +++ b/src/seedsigner/hardware/displays/ST7789.py @@ -1,169 +1,173 @@ -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 + + + +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) # 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..a41bb321 --- /dev/null +++ b/src/seedsigner/hardware/displays/display_driver.py @@ -0,0 +1,60 @@ +DISPLAY_TYPE__ST7789 = "st7789" +DISPLAY_TYPE__ILI9341 = "ili9341" +DISPLAY_TYPE__ILI9486 = "ili9486" + +ALL_DISPLAY_TYPES = [DISPLAY_TYPE__ST7789, DISPLAY_TYPE__ILI9341, DISPLAY_TYPE__ILI9486] + + +class DisplayDriver: + def __init__(self, display_type: str = DISPLAY_TYPE__ST7789, width: int = None, height: int = None): + if display_type not in ALL_DISPLAY_TYPES: + raise ValueError(f"Invalid display type: {display_type}") + self.display_type = display_type + + if self.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 + self.display = ST7789() + + elif width == 320: + from seedsigner.hardware.displays.st7789_mpy import ST7789 + # Have to swap width and height; screen is natively 240x320 + self.display = ST7789(width=height, height=width) + + elif self.display_type == DISPLAY_TYPE__ILI9341: + from seedsigner.hardware.displays.ili9341 import ILI9341 + self.display = ILI9341() + self.display.begin() + + elif self.display_type == DISPLAY_TYPE__ILI9486: + # TODO: improve performance of ili9486 driver + raise Exception("ILI9486 display not implemented yet") + + + def __str__(self): + return f"DisplayDriver(display_type={self.display_type}, width={self.width}, height={self.height})" + + + @property + def width(self): + return self.display.width + + + @property + def height(self): + return self.display.height + + + def invert(self, enabled: bool = True): + """Invert how the display interprets colors""" + self.display.invert(enabled) + + + def show_image(self, image, x_start: int = 0, y_start: int = 0): + self.display.show_image(image, x_start, y_start) \ No newline at end of file diff --git a/src/seedsigner/hardware/displays/ili9341.py b/src/seedsigner/hardware/displays/ili9341.py new file mode 100644 index 00000000..1330aa7c --- /dev/null +++ b/src/seedsigner/hardware/displays/ili9341.py @@ -0,0 +1,376 @@ +# 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 PIL import Image +from PIL import ImageDraw + +import RPi.GPIO as GPIO +from spidev import SpiDev + + +# 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() + + +class ILI9341(object): + """Representation of an ILI9341 TFT LCD.""" + + def __init__(self, dc=22, rst=13, led=12, width=ILI9341_TFTWIDTH, + height=ILI9341_TFTHEIGHT, rotation=90): + """Create an instance of the display using SPI communication. Must + provide the GPIO pin number for the D/C pin and the SPI driver. Can + optionally provide the GPIO pin number for the reset pin as the rst + parameter. + """ + 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.width = width + self.height = height + 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', (width, height)) + + # @property + # def width(self): + # return self.width + + # @property + # def height(self): + # return 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, state: 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 state: + self.command(ILI9341_INVON) + else: + self.command(ILI9341_INVOFF) + self.inverted = state + 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..5a7bd5f8 --- /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 math import sin, cos + +# +# 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 + + +class ST7789: + """ + 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 __init__( + self, + # spi, + width, + height, + 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(width, height) + if not self.rotations: + supported_displays = ", ".join( + [f"{display[0]}x{display[1]}" for display in _SUPPORTED_DISPLAYS] + ) + raise ValueError( + f"Unsupported {width}x{height} display. Supported displays: {supported_displays}" + ) + + if dc is None: + raise ValueError("dc pin is required.") + + self.physical_width = self.width = width + self.physical_height = self.height = 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, + self.height, + 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/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index 1135a140..b9bcedf4 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): @@ -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..37aeb788 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -344,6 +344,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/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..19eba895 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -123,7 +123,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 @@ -191,13 +192,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..46b76eb4 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -1,8 +1,13 @@ +import os 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 @@ -46,31 +51,166 @@ class SettingsConstants: (COORDINATOR__KEEPER, "Keeper"), ] + # 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__CHINESE_SIMPLIFIED: "(beta) 简体中文 (Chinese Simplified)", + LOCALE__JAPANESE: "(beta) 日本語 (Japanese)", + LOCALE__KOREAN: "(beta) 한국어 (Korean)", + + # --------- 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__CZECH: "čeština", + # LOCALE__CHINESE_TRADITIONAL: "繁體中文 (Chinese Traditional)", + LOCALE__DANISH: "Dansk", + LOCALE__ESTONIAN: "Eesti", + # LOCALE__EGYPTIAN: "مصرى (Egyptian)", + LOCALE__GAELIC: "Gaeilge", + LOCALE__GREEK: "Ελληνικά (Greek)", # OpenSans includes Greek chars + # LOCALE__GUJARATI: "ગુજરાતી (Gujarati)", + LOCALE__HAUSA: "Hausa", + # LOCALE__HEBREW: "עברית (Hebrew)", + # LOCALE__HINDI: "हिन्दी (Hindi)", + LOCALE__CROATIAN: "Hrvatski", + LOCALE__INDONESIAN: "Indonesia", + 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__NORWEGIAN: "Norsk", + # LOCALE__PERSIAN: "فارسی (Persian)", + LOCALE__POLISH: "Polski", + LOCALE__PORTUGUESE_BR: "Português (Brasil)", + LOCALE__PORTUGUESE_PT: "Português (Portugal)", + # LOCALE__PUNJABI: "ਪੰਜਾਬੀ (Punjabi)", + LOCALE__ROMANIAN: "Română", + LOCALE__RUSSIAN: "русский (Russian)", # OpenSans includes cyrillic chars + LOCALE__SLOVAK: "Slovenčina", + LOCALE__SLOVENIAN: "Slovenščina", + LOCALE__FINNISH: "Suomi", + LOCALE__SWEDISH: "Svenska", + LOCALE__TAGALOG: "Tagalog", + # LOCALE__TAMIL: "தமிழ் (Tamil)", + # LOCALE__TELUGU: "తెలుగు (Telugu)", + # LOCALE__THAI: "ไทย (Thai)", + LOCALE__TURKISH: "Türkçe", + LOCALE__UKRANIAN: "українська (Ukranian)", # OpenSans includes cyrillic chars + # LOCALE__URDU: "اردو (Urdu)", + LOCALE__VIETNAMESE: "Tiếng Việt (Vietnamese)", + } + + @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. + """ + # Will normally be the launch dir (where main.py is located)... + cwd = os.getcwd() + + # ...except when running the tests which happens one dir higher + if "src" not in cwd: + cwd = os.path.join(cwd, "src") + + # 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(cwd, "seedsigner", "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 + locales_present.add(root.split(f"l10n{ os.sep }")[1].split(os.sep)[0]) + + 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" @@ -172,7 +312,6 @@ 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" @@ -181,6 +320,9 @@ class SettingsConstants: SETTING__COORDINATORS = "coordinators" 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" @@ -200,6 +342,20 @@ class SettingsConstants: 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 +369,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 @@ -382,7 +539,7 @@ 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 @@ -542,6 +699,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, diff --git a/src/seedsigner/resources/fonts/NotoSansJP-Regular.ttf b/src/seedsigner/resources/fonts/NotoSansJP-Regular.ttf new file mode 100644 index 00000000..b2dad730 Binary files /dev/null and b/src/seedsigner/resources/fonts/NotoSansJP-Regular.ttf differ diff --git a/src/seedsigner/resources/fonts/NotoSansKR-Regular.ttf b/src/seedsigner/resources/fonts/NotoSansKR-Regular.ttf new file mode 100644 index 00000000..1b14d324 Binary files /dev/null and b/src/seedsigner/resources/fonts/NotoSansKR-Regular.ttf differ diff --git a/src/seedsigner/resources/fonts/NotoSansSC-Regular.ttf b/src/seedsigner/resources/fonts/NotoSansSC-Regular.ttf new file mode 100644 index 00000000..7056f5e9 Binary files /dev/null and b/src/seedsigner/resources/fonts/NotoSansSC-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..9f528ae0 160000 --- a/src/seedsigner/resources/seedsigner-translations +++ b/src/seedsigner/resources/seedsigner-translations @@ -1 +1 @@ -Subproject commit 10ee02ae23156ab97600a407b2da66b84e89d29d +Subproject commit 9f528ae075ed895f8c73a9d19360543536cbcc37 diff --git a/src/seedsigner/views/screensaver.py b/src/seedsigner/views/screensaver.py index d96f7f5d..d607f6ef 100644 --- a/src/seedsigner/views/screensaver.py +++ b/src/seedsigner/views/screensaver.py @@ -77,6 +77,8 @@ 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: @@ -87,11 +89,14 @@ class OpeningSplashScreen(LogoScreen): # 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()) @@ -144,17 +149,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 +209,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..cd7d9cc9 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -1558,16 +1558,16 @@ 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 def run(self): @@ -1594,8 +1594,8 @@ class SeedTranscribeSeedQRZoomedInView(View): seed_screens.SeedTranscribeSeedQRZoomedInScreen( qr_data=data, num_modules=num_modules, - initial_block_x=self.initial_block_x, - initial_block_y=self.initial_block_y, + initial_zone_x=self.initial_zone_x, + initial_zone_y=self.initial_zone_y, ).display() return Destination(SeedTranscribeSeedQRConfirmQRPromptView, view_args={"seed_num": self.seed_num}) @@ -1646,12 +1646,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 +1665,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) diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index b4fb86a6..b6fa7899 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,20 +85,66 @@ 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, @@ -179,6 +230,12 @@ 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 @@ -197,9 +254,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..2b9d6c27 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -5,7 +5,7 @@ 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 @@ -83,21 +83,25 @@ 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( @@ -127,54 +131,65 @@ class ToolsImageEntropyMnemonicLengthView(View): 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: + 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' - # 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) @@ -619,6 +634,7 @@ class ToolsAddressExplorerAddressListView(View): def run(self): + from seedsigner.gui.screens.tools_screens import ToolsAddressExplorerAddressListScreen self.loading_screen = None addresses = [] @@ -672,31 +688,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/tests/base.py b/tests/base.py index 74370dc7..e89174ca 100644 --- a/tests/base.py +++ b/tests/base.py @@ -11,6 +11,8 @@ 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.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 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..fce87164 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -6,6 +6,7 @@ import random import sys import time from unittest.mock import Mock, patch, MagicMock +from PIL import ImageFont from embit import compact from embit.psbt import PSBT, OutputScope @@ -13,17 +14,16 @@ 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.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.components import GUIConstants from seedsigner.gui.renderer import Renderer from seedsigner.gui.screens.seed_screens import SeedAddPassphraseScreen from seedsigner.gui.toast import RemoveSDCardToastManagerThread, SDCardStateChangeToastManagerThread @@ -45,10 +45,8 @@ 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 +56,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) @@ -160,36 +163,57 @@ 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 + + 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) + + # 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 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}" - # 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(): @@ -293,8 +317,8 @@ def generate_screenshots(locale): 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.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), @@ -428,7 +452,7 @@ def generate_screenshots(locale): 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,7 +502,7 @@ 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: 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_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_seed.py b/tests/test_flows_seed.py index c1bcc0bb..a9a7f663 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -435,6 +435,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") @@ -460,6 +463,12 @@ class TestSeedFlows(FlowTest): FlowStep(seed_views.SeedTranscribeSeedQRConfirmInvalidQRView), FlowStep(seed_views.SeedTranscribeSeedQRZoomedInView, is_redirect=True), # Live interactive screens are still weird + # 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, is_redirect=True), # Live interactive screens are still weird + # 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), diff --git a/tests/test_settings_definition.py b/tests/test_settings_definition.py new file mode 100644 index 00000000..f95a6c8b --- /dev/null +++ b/tests/test_settings_definition.py @@ -0,0 +1,38 @@ +import os +from unittest.mock import Mock + +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"])) + os.walk = Mock(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