diff --git a/.gitignore b/.gitignore index 0562adc..ac5172e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ release .vscode .DS_Store .direnv +src/git_info.py diff --git a/Makefile b/Makefile index f9b123b..92499fd 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ FROZEN_MANIFEST_DEBUG ?= ../../../../manifests/debug.py FROZEN_MANIFEST_UNIX ?= ../../../../manifests/unix.py DEBUG ?= 0 USE_DBOOT ?= 0 +GIT_INFO ?= src/git_info.py $(TARGET_DIR): mkdir -p $(TARGET_DIR) @@ -23,8 +24,13 @@ mpy-cross: $(TARGET_DIR) $(MPY_DIR)/mpy-cross/Makefile DEBUG=$(DEBUG) && \ cp $(MPY_DIR)/mpy-cross/mpy-cross $(TARGET_DIR) +# embed git metadata for firmware builds +.PHONY: git-info +git-info: + ./tools/embed_git_info.py $(GIT_INFO) + # disco board with bitcoin library -disco: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/stm32 +disco: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/stm32 git-info @echo Building firmware make -C $(MPY_DIR)/ports/stm32 \ BOARD=$(BOARD) \ @@ -40,7 +46,7 @@ disco: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/stm32 $(TARGET_DIR)/specter-diy.hex # disco board with bitcoin library -debug: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/stm32 +debug: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/stm32 git-info @echo Building firmware make -C $(MPY_DIR)/ports/stm32 \ BOARD=$(BOARD) \ @@ -57,7 +63,7 @@ debug: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/stm32 # unixport (simulator) -unix: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/unix +unix: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/unix git-info @echo Building binary with frozen files make -C $(MPY_DIR)/ports/unix \ USER_C_MODULES=$(USER_C_MODULES) \ @@ -83,4 +89,4 @@ clean: USER_C_MODULES=$(USER_C_MODULES) \ FROZEN_MANIFEST=$(FROZEN_MANIFEST_DISCO) clean -.PHONY: all clean +.PHONY: all clean git-info diff --git a/boot/debug/boot.py b/boot/debug/boot.py index fdba03e..0cf38a7 100755 --- a/boot/debug/boot.py +++ b/boot/debug/boot.py @@ -37,8 +37,10 @@ def poweroff(_): # os.dupterm(None,1) # inject version to platform module -import platform -platform.version = version +import platform +platform.version = version +platform.bootloader_locked = False +platform.build_type = "debug" # uncomment to run some custom main: pyb.main("hardwaretest.py") diff --git a/boot/main/boot.py b/boot/main/boot.py index 727d90d..831f34d 100755 --- a/boot/main/boot.py +++ b/boot/main/boot.py @@ -59,6 +59,8 @@ def poweroff(_): os.dupterm(None,1) # inject version and i2c to platform module -import platform -platform.version = version -platform.i2c = i2c +import platform +platform.version = version +platform.i2c = i2c +platform.bootloader_locked = True +platform.build_type = "disco" diff --git a/src/gui/screens/settings.py b/src/gui/screens/settings.py index 8c4bb56..fe74796 100644 --- a/src/gui/screens/settings.py +++ b/src/gui/screens/settings.py @@ -29,8 +29,10 @@ def __init__(self, controls, title="Host setttings", note=None, controls_empty_t switch.on(lv.ANIM.OFF) self.switches.append(switch) y = lbl.get_y() + 80 - else: + self.next_y = y + if not controls: label = add_label(controls_empty_text, y, scr=self.page) + self.next_y = label.get_y() + label.get_height() + 40 self.confirm_button.set_event_cb(on_release(self.update)) self.cancel_button.set_event_cb(on_release(lambda: self.set_value(None))) diff --git a/src/hosts/qr.py b/src/hosts/qr.py index c5a5b7e..dfd3425 100644 --- a/src/hosts/qr.py +++ b/src/hosts/qr.py @@ -2,8 +2,11 @@ import pyb import time import asyncio -from platform import simulator, config, delete_recursively +from platform import simulator, config, delete_recursively, file_exists, sync import gc +import lvgl as lv +from gui.common import add_button, add_label +from gui.decorators import on_release from gui.screens.settings import HostSettings from gui.screens import Alert from helpers import read_until, read_write, a2b_base64_stream @@ -17,6 +20,9 @@ SERIAL_ADDR = b"\x00\x0D" SERIAL_VALUE = 0xA0 # use serial port for data +# factory reset command (restore defaults) +FACTORY_RESET_CMD = b"\x7E\x00\x08\x01\x00\xD9\x55\xAB\xCD" + """ We switch the scanner to continuous mode to initiate scanning and to command mode to stop scanning. No external trigger is necessary """ SETTINGS_ADDR = b"\x00\x00" @@ -41,6 +47,16 @@ DELAY_OF_SAME_BARCODES_ADDR = b"\x00\x13" DELAY_OF_SAME_BARCODES = 0x85 # 5 seconds +""" Address for software version check. +(The ability to check firmware version via this memory was removed from newer GM65 type devices, but still lets us identify versions that need a fix)""" +VERSION_ADDR = b"\x00\xE2" +VERSION_NEEDS_RAW = 0x69 # A version of GM65 that needs RAW mode to be turned on... + +""" Some newer GM65 Scanners need to be set to "RAW" mode to correctly scan binary QR codes. +(The address for this is actually undocumented and doesn't seem to work the same on newer varients like the GM805)""" +RAW_MODE_ADDR = b"\x00\xBC" +RAW_MODE_VALUE = 0x08 + class QRHost(Host): """ @@ -56,6 +72,9 @@ class QRHost(Host): button = "Scan QR code" settings_button = "QR scanner" + # Some Information to report device to user + version_str = "No Scanner Detected" + def __init__(self, path, trigger=None, uart="YA", baudrate=9600): super().__init__(path) @@ -65,14 +84,30 @@ def __init__(self, path, trigger=None, uart="YA", baudrate=9600): "aim": True, "light": False, "sound": True, + # internal flag that indicates whether RAW compatibility fix + # has been applied and persisted on the scanner + "raw_fix_applied": False, } + self._initial_reset_marker = None + self._boot_reset_pending = False + if self.SETTINGS_DIR: + marker = self.SETTINGS_DIR + "/.qr_factory_reset_done" + self._initial_reset_marker = marker + try: + self._boot_reset_pending = not file_exists(marker) + except Exception as e: + # Avoid repeated attempts if storage is unavailable. + print("QRHost: failed to check reset marker:", e) + self._boot_reset_pending = False + if simulator: self.EOL = b"\r\n" else: self.EOL = b"\r" self.f = None + self.software_version = None self.uart_bus = uart self.uart = pyb.UART(uart, baudrate, read_buf_len=2048) if simulator: @@ -90,13 +125,13 @@ def __init__(self, path, trigger=None, uart="YA", baudrate=9600): @property def MASK(self): - b = (1<<7) + b = (1 << 7) if self.settings.get("sound", True): - b |= (1<<6) + b |= (1 << 6) if self.settings.get("aim", True): - b |= (1<<4) + b |= (1 << 4) if self.settings.get("light", False): - b |= (1<<2) + b |= (1 << 2) return b @property @@ -119,68 +154,156 @@ def query(self, data, timeout=100): res = self.uart.read(7) return res - def get_setting(self, addr): + def _get_setting_once(self, addr): # only for 1 byte settings res = self.query(b"\x7E\x00\x07\x01" + addr + b"\x01\xAB\xCD") if res is None or len(res) != 7: return None return res[-3] - def set_setting(self, addr, value): + def get_setting(self, addr, retries=3, retry_delay_ms=50): + for attempt in range(retries): + val = self._get_setting_once(addr) + if val is not None: + return val + time.sleep_ms(retry_delay_ms) + return None + + def _set_setting_once(self, addr, value): # only for 1 byte settings res = self.query(b"\x7E\x00\x08\x01" + addr + bytes([value]) + b"\xAB\xCD") if res is None: return False return res == SUCCESS - def save_settings_on_scanner(self): - res = self.query(b"\x7E\x00\x09\x01\x00\x00\x00\xDE\xC8") - if res is None: - return False - return res == SUCCESS + def set_setting(self, addr, value, retries=3, retry_delay_ms=50): + for attempt in range(retries): + if self._set_setting_once(addr, value): + return True + time.sleep_ms(retry_delay_ms) + self.clean_uart() + return False + + def save_settings_on_scanner(self, retries=3, retry_delay_ms=100): + for attempt in range(retries): + res = self.query(b"\x7E\x00\x09\x01\x00\x00\x00\xDE\xC8") + if res == SUCCESS: + return True + time.sleep_ms(retry_delay_ms) + self.clean_uart() + return False def configure(self): """Tries to configure scanner, returns True on success""" save_required = False + settings_changed = False + raw_fix_applied = self.settings.get("raw_fix_applied", False) + + # Set Serial Output Mode val = self.get_setting(SERIAL_ADDR) if val is None: return False if val & 0x3 != 0: - self.set_setting(SERIAL_ADDR, val & 0xFC) + if not self.set_setting(SERIAL_ADDR, val & 0xFC): + return False save_required = True + # Set Command Mode val = self.get_setting(SETTINGS_ADDR) if val is None: return False if val != self.CMD_MODE: - self.set_setting(SETTINGS_ADDR, self.CMD_MODE) + if not self.set_setting(SETTINGS_ADDR, self.CMD_MODE): + return False save_required = True + # Set scanning timeout val = self.get_setting(TIMOUT_ADDR) if val is None: return False if val != 0: - self.set_setting(TIMOUT_ADDR, 0) + if not self.set_setting(TIMOUT_ADDR, 0): + return False save_required = True + # Set interval between scans val = self.get_setting(INTERVAL_OF_SCANNING_ADDR) if val is None: return False if val != INTERVAL_OF_SCANNING: - self.set_setting(INTERVAL_OF_SCANNING_ADDR, INTERVAL_OF_SCANNING) + if not self.set_setting(INTERVAL_OF_SCANNING_ADDR, INTERVAL_OF_SCANNING): + return False save_required = True + # Set delay beteen re-reading the same barcode val = self.get_setting(DELAY_OF_SAME_BARCODES_ADDR) if val is None: return False if val != DELAY_OF_SAME_BARCODES: - self.set_setting(DELAY_OF_SAME_BARCODES_ADDR, DELAY_OF_SAME_BARCODES) + if not self.set_setting(DELAY_OF_SAME_BARCODES_ADDR, DELAY_OF_SAME_BARCODES): + return False save_required = True + # Check the module software and enable "RAW" mode if required + val = self.get_setting(VERSION_ADDR) + if val is None: + return False + self.software_version = val + self.version_str = "Detected GM65 Scanner, SW:" + str(val) + if val == VERSION_NEEDS_RAW: + val = self.get_setting(RAW_MODE_ADDR) + if val is None: + return False + if val != RAW_MODE_VALUE: + if not self.set_setting(RAW_MODE_ADDR, RAW_MODE_VALUE): + return False + # Re-read to confirm the scanner accepted the value, retrying + # once more if necessary. Some scanners take a short while to + # commit this particular setting right after power-on. + val_check = self.get_setting(RAW_MODE_ADDR) + if val_check is None: + return False + if val_check != RAW_MODE_VALUE: + if not self.set_setting(RAW_MODE_ADDR, RAW_MODE_VALUE, retries=1, retry_delay_ms=100): + return False + val_check = self.get_setting(RAW_MODE_ADDR) + if val_check is None or val_check != RAW_MODE_VALUE: + return False + save_required = True + if not raw_fix_applied: + raw_fix_applied = True + settings_changed = True + elif raw_fix_applied: + # Clear the flag if we are no longer dealing with a scanner that + # requires the RAW mode compatibility tweak. + raw_fix_applied = False + settings_changed = True + + # Save settings to EEPROM if anything has changed. if save_required: val = self.save_settings_on_scanner() if not val: return False + settings_changed = True + + if settings_changed and self.manager is not None: + keystore = getattr(self.manager, "keystore", None) + if keystore is not None: + # persist the updated host settings (including the + # compatibility flag) so the device keeps track of whether + # the RAW mode fix has already been applied + self.settings["raw_fix_applied"] = raw_fix_applied + try: + self.save_settings(keystore) + except Exception as e: + print("Failed to persist QR host settings:", e) + else: + # still update the in-memory settings to reflect the current + # state even if we cannot persist them yet + self.settings["raw_fix_applied"] = raw_fix_applied + else: + # keep the internal flag in sync if no persistence step occurred + self.settings["raw_fix_applied"] = raw_fix_applied # Set 115200 bps: this query is special - it has a payload of 2 bytes ret = self.query(b"\x7E\x00\x08\x02" + BAUD_RATE_ADDR + BAUD_RATE + b"\xAB\xCD") @@ -193,6 +316,13 @@ def configure(self): def init(self): if self.is_configured: return + if self._boot_reset_pending: + success = self._factory_reset_scanner_on_boot() + self._boot_reset_pending = False + if success: + return + else: + print("QRHost: automatic factory reset failed, continuing with configuration") # if failed to configure - probably a different scanner # in this case fallback to PIN trigger mode FIXME self.clean_uart() @@ -216,8 +346,80 @@ def init(self): self.is_configured = True pyb.LED(3).on() + def _format_scanner_info(self): + version = self.software_version + version_text = "unknown" if version is None else str(version) + raw_fix_applied = self.settings.get("raw_fix_applied", False) + if version is None: + raw_fix = "Unknown" + elif version == VERSION_NEEDS_RAW: + raw_fix = "Applied" if raw_fix_applied else "Not applied" + else: + raw_fix = "Not needed" + return "Scanner: GM65 | Firmware: {} | CompactQR fix: {}".format( + version_text, + raw_fix, + ) + + def _mark_initial_reset_done(self): + if not self._initial_reset_marker: + return + try: + with open(self._initial_reset_marker, "wb") as f: + f.write(b"1") + sync() + except Exception as e: + print("QRHost: failed to persist reset marker:", e) + + def _apply_post_reset_configuration(self, settings_snapshot, previous_settings): + self.uart.deinit() + self.uart.init(baudrate=9600, read_buf_len=2048) + self.clean_uart() + self.settings = settings_snapshot + configured = self.configure() + if not configured: + self.settings = previous_settings + return False + self.is_configured = True + return True + + def _send_factory_reset(self): + res = self.query(FACTORY_RESET_CMD) + return res == SUCCESS + + def _factory_reset_scanner_on_boot(self): + previous_settings = dict(self.settings) + settings_snapshot = dict(previous_settings) + settings_snapshot["raw_fix_applied"] = False + self.clean_uart() + if not self._send_factory_reset(): + return False + time.sleep_ms(200) + if not self._apply_post_reset_configuration(settings_snapshot, previous_settings): + return False + self._mark_initial_reset_done() + return True + + async def _factory_reset_scanner(self, keystore): + previous_settings = dict(self.settings) + settings_snapshot = dict(previous_settings) + settings_snapshot["raw_fix_applied"] = False + self.clean_uart() + if not self._send_factory_reset(): + return False + await asyncio.sleep_ms(200) + if not self._apply_post_reset_configuration(settings_snapshot, previous_settings): + return False + if keystore is not None: + try: + self.save_settings(keystore) + except Exception as e: + print("Failed to persist QR host settings:", e) + return True + async def settings_menu(self, show_screen, keystore): title = "QR scanner" + note = self.version_str controls = [{ "label": "Enable QR scanner", "hint": "Enable or disable QR scanner and remove corresponding button from the main menu", @@ -235,18 +437,66 @@ async def settings_menu(self, show_screen, keystore): "hint": "Can create blicks on the screen", "value": self.settings.get("light", False) }] - scr = HostSettings(controls, title=title) + scr = HostSettings(controls, title=title, note=note) + info_y = scr.next_y + 20 + info = add_label( + self._format_scanner_info(), + y=info_y, + scr=scr.page, + style="hint", + ) + + reset_y = info.get_y() + info.get_height() + 30 + + def trigger_factory_reset(): + scr.show_loader( + text="Resetting scanner to defaults...", + title="Factory reset", + ) + scr.set_value("factory_reset") + + reset_btn = add_button( + lv.SYMBOL.REFRESH + " Factory reset", + on_release(trigger_factory_reset), + scr=scr.page, + y=reset_y, + ) res = await show_screen(scr) + if res == "factory_reset": + scr.hide_loader() + success = await self._factory_reset_scanner(keystore) + if success: + await show_screen( + Alert( + "Success!", + "\n\nScanner restored and settings re-applied.", + button_text="Close", + ) + ) + else: + await show_screen( + Alert( + "Error", + "\n\nFailed to factory reset scanner!", + button_text="Close", + ) + ) + return await self.settings_menu(show_screen, keystore) + if res: enabled, sound, aim, light = res + raw_fix_applied = self.settings.get("raw_fix_applied", False) self.settings = { "enabled": enabled, "aim": aim, "light": light, "sound": sound, + "raw_fix_applied": raw_fix_applied, } self.save_settings(keystore) - self.configure() + if not self.configure(): + await show_screen(Alert("Error", "\n\nFailed to configure scanner!", button_text="Close")) + return await show_screen(Alert("Success!", "\n\nSettings updated!", button_text="Close")) def clean_uart(self): @@ -254,7 +504,7 @@ def clean_uart(self): def _stop_scanner(self): if self.trigger is not None: - self.trigger.on() # trigger is reversed, so on means disable + self.trigger.on() # trigger is reversed, so on means disable else: self.set_setting(SCAN_ADDR, 0) @@ -278,21 +528,21 @@ def stop_scanning(self): self._stop_scanner() def abort(self): - with open(self.tmpfile,"wb"): + with open(self.tmpfile, "wb"): pass self.cancelled = True self.stop_scanning() @property def tmpfile(self): - return self.path+"/tmp" + return self.path + "/tmp" async def scan(self, raw=True, chunk_timeout=0.5): self.raw = raw self.chunk_timeout = chunk_timeout self._start_scanner() # clear the data - with open(self.tmpfile,"wb") as f: + with open(self.tmpfile, "wb") as f: pass if self.f is not None: self.f.close() @@ -319,14 +569,14 @@ async def scan(self, raw=True, chunk_timeout=0.5): gc.collect() if self.cancelled: return None - self.f = open(self.path+"/data.txt", "rb") + self.f = open(self.path + "/data.txt", "rb") return self.f def check_animated(self, data: bytes): try: # should be only ascii characters d = data.decode().strip().lower() - if d.startswith("ur:"): # ur:bytes or ur:crypto-psbt + if d.startswith("ur:"): # ur:bytes or ur:crypto-psbt return True # this will raise if it's not a valid prefix self.parse_prefix(data.split(b" ")[0]) @@ -342,7 +592,7 @@ async def update(self): return # read all available data if self.uart.any() > 0: - if not self.animated: # read only one QR code + if not self.animated: # read only one QR code # let all data to come on the first QR code await asyncio.sleep(self.chunk_timeout) d = self.uart.read() @@ -361,14 +611,14 @@ async def update(self): d = self.uart.read() # no new lines - just write and continue if d[-len(self.EOL):] != self.EOL: - with open(self.tmpfile,"ab") as f: + with open(self.tmpfile, "ab") as f: f.write(d) return # restart scan while processing data await self._restart_scanner() # slice to write d = d[:-len(self.EOL)] - with open(self.tmpfile,"ab") as f: + with open(self.tmpfile, "ab") as f: f.write(d) try: if self.process_chunk(): @@ -451,7 +701,7 @@ def process_bcur(self, f): # allocate stuff self.animated = True self.parts = [None] * n - fname = "%s/p%d.txt" % (self.path, m-1) + fname = "%s/p%d.txt" % (self.path, m - 1) with open(fname, "wb") as fout: read_write(f, fout) self.parts[m - 1] = fname @@ -467,7 +717,7 @@ def process_bcur(self, f): if hsh != self.bcur_hash: print(hsh, self.bcur_hash) raise HostError("Checksum mismatch") - fname = "%s/p%d.txt" % (self.path, m-1) + fname = "%s/p%d.txt" % (self.path, m - 1) with open(fname, "wb") as fout: fout.write(f.read()) self.parts[m - 1] = fname @@ -509,7 +759,7 @@ def process_normal(self, f): # allocate stuff self.animated = True self.parts = [None] * n - fname = "%s/p%d.txt" % (self.path, m-1) + fname = "%s/p%d.txt" % (self.path, m - 1) with open(fname, "wb") as fout: read_write(f, fout) self.parts[m - 1] = fname @@ -533,7 +783,7 @@ def process_normal(self, f): m, n = self.parse_prefix(chunk) if n != len(self.parts): raise HostError("Invalid prefix") - fname = "%s/p%d.txt" % (self.path, m-1) + fname = "%s/p%d.txt" % (self.path, m - 1) with open(fname, "wb") as fout: read_write(f, fout) self.parts[m - 1] = fname @@ -580,7 +830,7 @@ async def send_data(self, stream, meta, *args, **kwargs): note = meta.get("note") start = stream.read(4) stream.seek(-len(start), 1) - if start in [b"cHNi", b"cHNl"]: # convert from base64 for QR encoder + if start in [b"cHNi", b"cHNl"]: # convert from base64 for QR encoder with open(self.tmpfile, "wb") as f: a2b_base64_stream(stream, f) with open(self.tmpfile, "rb") as f: @@ -592,13 +842,13 @@ async def send_data(self, stream, meta, *args, **kwargs): return await self.manager.gui.qr_alert(title, msg, response, note=note, qr_width=480) EncoderCls = None - if self.bcur2: # we need binary + if self.bcur2: # we need binary from qrencoder import CryptoPSBTEncoder as EncoderCls elif self.bcur: from qrencoder import LegacyBCUREncoder as EncoderCls else: from qrencoder import Base64QREncoder as EncoderCls - with EncoderCls(stream, tempfile=self.path+"/qrtmp") as enc: + with EncoderCls(stream, tempfile=self.path + "/qrtmp") as enc: await self.manager.gui.qr_alert(title, "", enc, note=note, qr_width=480) @property diff --git a/src/platform.py b/src/platform.py index 9cf443f..e0c9df7 100644 --- a/src/platform.py +++ b/src/platform.py @@ -6,6 +6,16 @@ simulator = (sys.platform in ["linux", "darwin"]) + +# Build metadata injected at boot time. Defaults represent the minimum +# information we can know without platform-specific boot scripts. +bootloader_locked = None +build_type = "unknown" + +if simulator: + build_type = "unix" + bootloader_locked = False + try: import config except: @@ -13,9 +23,12 @@ if not simulator: import sdram + import stm + sdram.init() else: _PREALLOCATED = bytes(0x100000) + stm = None # injected by the boot.py i2c = None # I2C to talk to the battery @@ -117,6 +130,28 @@ def fpath(fname): storage_root = "" sdcard = SDCard(pyb.SDCard(), pyb.LED(4)) +def get_git_info(): + """Return repository metadata embedded into the firmware build.""" + + repo = "unknown" + branch = "unknown" + commit = "unknown" + + try: + from git_info import REPOSITORY, BRANCH, COMMIT + + if REPOSITORY: + repo = REPOSITORY + if BRANCH: + branch = BRANCH + if COMMIT: + commit = COMMIT + except: + pass + + return repo, branch, commit + + def get_version() -> str: # version is coming from boot.py if running on the hardware try: @@ -132,6 +167,82 @@ def get_version() -> str: except: return "unknown" + +def get_bootloader_lock_status() -> str: + if bootloader_locked is True: + return "locked" + if bootloader_locked is False: + return "unlocked" + return "unknown" + + +def get_build_type() -> str: + return build_type + + +def get_firmware_boot_mode() -> str: + """Return boot mode based on the current vector table address.""" + + if simulator: + return "simulator" + + try: + vtor = stm.mem32[0xE000ED08] + except Exception: + return "unknown" + + if vtor >= 0x08020000: + return "bootloader" + if vtor >= 0x08000000: + return "open" + return "unknown" + + +def get_flash_read_protection_status() -> str: + """Return human readable read protection status.""" + + if simulator: + return "not applicable" + + try: + option_control = stm.mem32[0x40023C14] + except Exception: + return "unknown" + + read_level = (option_control >> 8) & 0xFF + + if read_level == 0xAA: + return "disabled" + if read_level == 0xCC: + return "enabled (level 2)" + return "enabled (level 1)" + + +def get_flash_write_protection_status() -> str: + """Return human readable write protection status.""" + + if simulator: + return "not applicable" + + try: + option_control = stm.mem32[0x40023C14] + except Exception: + return "unknown" + + lower = (option_control >> 16) & 0xFFFF + upper = 0xFFFF + + if stm is not None: + try: + option_control_1 = stm.mem32[0x40023C18] + upper = option_control_1 & 0xFFFF + except Exception: + pass + + if lower == 0xFFFF and upper == 0xFFFF: + return "disabled" + return "enabled" + def mount_sdram(): path = fpath("/ramdisk") if simulator: diff --git a/src/specter.py b/src/specter.py index 06a3500..10a8528 100644 --- a/src/specter.py +++ b/src/specter.py @@ -10,7 +10,12 @@ maybe_mkdir, wipe, get_version, + get_git_info, get_battery_status, + get_build_type, + get_firmware_boot_mode, + get_flash_read_protection_status, + get_flash_write_protection_status, ) from hosts import Host, HostError from app import BaseApp @@ -53,6 +58,60 @@ def __init__(self, gui, keystores, hosts, apps, settings_path, network="main"): self.dev = False self.apps = apps + def _firmware_note(self, include_details=False): + primary_note = "Firmware version %s" % get_version() + + if not include_details: + return primary_note + + sections = [primary_note] + + repo, branch, commit = get_git_info() + repo_details = [] + if repo != "unknown": + repo_details.append("Repo: %s" % repo) + if branch != "unknown": + repo_details.append("Branch: %s" % branch) + if commit != "unknown": + repo_details.append("Commit: %s" % commit) + if repo_details: + sections.append("\n".join(repo_details)) + + def _format_status(value): + if isinstance(value, str) and value: + return value[0].upper() + value[1:] + return value + + boot_mode = get_firmware_boot_mode() + if boot_mode != "unknown": + boot_mode_note = "Firmware mode: %s" % _format_status(boot_mode) + else: + boot_mode_note = "Firmware mode: Unknown" + sections.append(boot_mode_note) + + read_protect = get_flash_read_protection_status() + if read_protect != "unknown": + read_note = "Read protection: %s" % _format_status(read_protect) + else: + read_note = "Read protection: Unknown" + sections.append(read_note) + + write_protect = get_flash_write_protection_status() + if write_protect != "unknown": + write_note = "Write protection: %s" % _format_status(write_protect) + else: + write_note = "Write protection: Unknown" + sections.append(write_note) + + build_type = get_build_type() + if build_type == "unknown": + build_note = "Build type: Unknown" + else: + build_note = "Build type: %s" % _format_status(build_type) + sections.append(build_note) + + return "\n\n".join(sections) + def start(self): # register battery monitor (runs every 3 seconds) self.gui.set_battery_callback(get_battery_status, 3000) @@ -344,8 +403,9 @@ async def settingsmenu(self): if hasattr(self.keystore, "show_mnemonic"): buttons.append((3, "Show recovery phrase")) buttons.extend([(None, "Security"), (4, "Device settings")]) # delimiter + buttons.extend([(None, "About"), (6, "About this device")]) # wait for menu selection - menuitem = await self.gui.menu(buttons, last=(255, None), note="Firmware version %s" % get_version()) + menuitem = await self.gui.menu(buttons, last=(255, None), note=self._firmware_note()) # process the menu button: # back button @@ -368,6 +428,8 @@ async def settingsmenu(self): await self.update_devsettings() elif menuitem == 5: await self.select_network() + elif menuitem == 6: + await self.show_about() else: print(menuitem) raise SpecterError("Not implemented") @@ -410,6 +472,13 @@ def load_network(self, path, network="main"): pass self.set_network(network) + async def show_about(self): + await self.gui.alert( + "About this device", + self._firmware_note(include_details=True), + button_text="Close", + ) + async def communication_settings(self): buttons = [ (None, "Communication channels") @@ -421,7 +490,7 @@ async def communication_settings(self): while True: menuitem = await self.gui.menu(buttons, title="Communication settings", - note="Firmware version %s" % get_version(), + note=self._firmware_note(), last=(255, None) ) if menuitem == 255: @@ -501,6 +570,7 @@ async def update_devsettings(self): # (3, "Experimental"), ] + [ (None, "Global settings"), + (42, "About this device"), ] if hasattr(self.keystore, "lock"): buttons.extend([(777, "Change PIN code")]) @@ -511,7 +581,7 @@ async def update_devsettings(self): while True: menuitem = await self.gui.menu(buttons, title="Device settings", - note="Firmware version %s" % get_version(), + note=self._firmware_note(), last=(255, None) ) if menuitem == 255: @@ -538,6 +608,9 @@ async def update_devsettings(self): elif menuitem == 777: await self.keystore.change_pin() return + elif menuitem == 42: + await self.show_about() + return elif menuitem == 1: await self.communication_settings() else: diff --git a/tools/embed_git_info.py b/tools/embed_git_info.py new file mode 100755 index 0000000..d6fda88 --- /dev/null +++ b/tools/embed_git_info.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Generate git metadata for embedding into frozen MicroPython modules.""" + +from __future__ import annotations + +import argparse +import subprocess +from pathlib import Path +from typing import Optional + +UNKNOWN_VALUE = "unknown" + + +def _run_git(args: list[str]) -> Optional[str]: + try: + result = subprocess.check_output(["git", *args], stderr=subprocess.DEVNULL) + except (OSError, subprocess.CalledProcessError): + return None + return result.decode().strip() or None + + +def discover_repository() -> str: + repo = _run_git(["config", "--get", "remote.origin.url"]) + if repo: + return repo + path = _run_git(["rev-parse", "--show-toplevel"]) + return path or UNKNOWN_VALUE + + +def discover_branch() -> str: + branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"]) + if branch and branch != "HEAD": + return branch + describe = _run_git(["describe", "--all"]) + if describe: + return describe + return "detached" + + +def discover_commit() -> str: + commit = _run_git(["rev-parse", "--short", "HEAD"]) + if commit: + return commit + return UNKNOWN_VALUE + + +def build_content(repository: str, branch: str, commit: str) -> str: + return ( + "# This file is auto-generated by tools/embed_git_info.py\n" + "REPOSITORY = %r\n" + "BRANCH = %r\n" + "COMMIT = %r\n" % (repository, branch, commit) + ) + + +def write_git_info(path: Path) -> None: + repository = discover_repository() + branch = discover_branch() + commit = discover_commit() + + content = build_content(repository, branch, commit) + + path.parent.mkdir(parents=True, exist_ok=True) + + try: + existing = path.read_text() + except FileNotFoundError: + existing = None + + if existing == content: + return + + path.write_text(content) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "output", + nargs="?", + default="src/git_info.py", + help="path to the generated git_info module", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + write_git_info(Path(args.output)) + + +if __name__ == "__main__": + main()