Fix for newer GM65 Scanners scanning CompactSeedQR + Add "About Screen" (#299)
What changed, and why it matters
This commit is primarily a hardware-compatibility fix for newer GM65 QR scanners used with the Specter DIY Bitcoin hardware wallet. It adds logic to detect a specific scanner firmware version and enable a special 'RAW' mode so binary QR codes (CompactSeedQR) scan correctly. It also adds an 'About this device' screen showing firmware and security metadata, and embeds git build info into firmware images. There is no obvious security vulnerability being patched; the changes are defensive and improve device transparency.
No immediate security action required. Reviewers may want to verify that the new factory reset command and RAW mode addresses do not have unintended side effects on non-GM65 scanners, and that the retry logic cannot be abused to cause persistent misconfiguration. The added transparency features (About screen, flash protection status) are positive security enhancements.
Security signals we found
Adds STM32 flash read/write protection status reporting (RDP level, write protection)
Adds firmware boot mode detection via VTOR address
Adds bootloader lock status reporting
Adds factory reset capability for QR scanner module
Adds retry and validation logic for scanner UART configuration commands
No direct diff evidence of a vulnerability fix or exploit mitigation
Evidence from the diff
The patch modifies src/hosts/qr.py to query the GM65 scanner’s software version (VERSION_ADDR 0x00E2) and, if it matches VERSION_NEEDS_RAW (0x69), enable an undocumented RAW mode (RAW_MODE_ADDR 0x00BC, value 0x08) required for binary QR decoding. It adds retry logic for UART configuration commands, a one-time factory reset on boot to clear potentially incompatible scanner settings, and persistence of a ‘raw_fix_applied’ flag. src/platform.py and boot scripts gain build metadata injection (bootloader_locked, build_type) and helpers to read STM32 flash protection status, boot mode via VTOR, and embedded git info. src/specter.py adds an About screen displaying this metadata. tools/embed_git_info.py is a new build-time script to generate src/git_info.py.
Changed components
src/hosts/qr.py (GM65 QR scanner driver)src/platform.py (build/runtime metadata and STM32 status helpers)src/specter.py (About screen and menu integration)boot/debug/boot.py and boot/main/boot.py (boot metadata injection)tools/embed_git_info.py (new build helper)Makefile (git-info build step)Inspect captured patch +596 / −48
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 eb19afa..18a6a8f 100644
--- a/Makefile
+++ b/Makefile
@@ -13,6 +13,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)
@@ -29,8 +30,13 @@ mpy-cross: $(TARGET_DIR) $(MPY_DIR)/mpy-cross/Makefile
CFLAGS_EXTRA="$(MPY_CFLAGS)" && \
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) \
@@ -47,7 +53,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) \
@@ -65,7 +71,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) \
@@ -92,4 +98,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 @@ pyb.ExtInt(pyb.Pin('B1'), pyb.ExtInt.IRQ_FALLING, pyb.Pin.PULL_NONE, pwrcb)
# 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 @@ os.dupterm(None,0)
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 @@ class HostSettings(Prompt):
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..f4ab372 100644
--- a/src/hosts/qr.py
+++ b/src/hosts/qr.py
@@ -2,8 +2,11 @@ from .core import Host, HostError
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 @@ SUCCESS = b"\x02\x00\x00\x01\x00\x33\x31"
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 @@ which happens if we scan a different qr code. """
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 @@ class QRHost(Host):
"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 @@ class QRHost(Host):
@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,164 @@ class QRHost(Host):
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, invalid_values=None):
+ if invalid_values:
+ invalid_values = set(invalid_values)
+ else:
+ invalid_values = None
+ for attempt in range(retries):
+ val = self._get_setting_once(addr)
+ if val is None or (invalid_values is not None and val in invalid_values):
+ time.sleep_ms(retry_delay_ms)
+ self.clean_uart()
+ continue
+ return val
+ 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, retries=5, retry_delay_ms=100, invalid_values=(0,)
+ )
+ 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 +324,13 @@ class QRHost(Host):
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 +354,80 @@ class QRHost(Host):
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 +445,66 @@ class QRHost(Host):
"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 + " Reset QR Module",
+ 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 +512,7 @@ class QRHost(Host):
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 +536,21 @@ class QRHost(Host):
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 +577,14 @@ class QRHost(Host):
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 +600,7 @@ class QRHost(Host):
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 +619,14 @@ class QRHost(Host):
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 +709,7 @@ class QRHost(Host):
# 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 +725,7 @@ class QRHost(Host):
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 +767,7 @@ class QRHost(Host):
# 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 +791,7 @@ class QRHost(Host):
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 +838,7 @@ class QRHost(Host):
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 +850,13 @@ class QRHost(Host):
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 @@ import gc
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 @@ except:
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 @@ else:
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 8843d7b..e1ff747 100644
--- a/src/specter.py
+++ b/src/specter.py
@@ -10,7 +10,12 @@ from platform import (
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 @@ class Specter:
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 @@ class Specter:
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 @@ class Specter:
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 @@ class Specter:
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 @@ class Specter:
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 @@ class Specter:
# (3, "Experimental"),
] + [
(None, "Global settings"),
+ (42, "About this device"),
]
if hasattr(self.keystore, "lock"):
buttons.extend([(777, "Change PIN code")])
@@ -511,7 +581,7 @@ class Specter:
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 @@ class Specter:
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()
Why this scored 21/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.